Compare commits

..

20 Commits

Author SHA1 Message Date
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
103 changed files with 4816 additions and 2839 deletions

View File

@ -38,11 +38,11 @@ Key ADRs to keep in mind when changing the library:
| 002 | 32-bit Entity (24 ID + 8 version) | Fits in register, enough headroom |
| 003 | Explicit query iteration | Visible cost model, no code-gen needed |
| 004 | Registration order for systems | Simplest model that works at this scale |
| 005 | Manual `MarkModified` for value changes | No per-component overhead |
| 006 | Deferred change posting | Prevents mid-system reentrancy |
| 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 as reserved entity (ID 1) | Reuses component storage |
| 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 |
@ -69,19 +69,32 @@ Dense/sparse array pair. `dense[]` is packed values, `denseEntities[]` is
parallel entity IDs, `sparse[]` maps entity ID → dense index (-1 = absent).
Uses swap-remove for O(1) deletion.
### `EntityIterator.Select1/2/3<T...>` — ref struct iterators
### `WorldQueryExtensions.Select1..Select6<T...>` — ref struct iterators
Zero-allocation iterators. Drive from the smallest sparse set to minimize
probes. Support `foreach` via `GetEnumerator()` returning `this`. Must call
`world.BeginIteration()` / `EndIteration()` for pending mutation flushing.
Always skip singleton entity (ID 1).
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. For each system: run it → drain commands → post changes.
3. Drain commands + post changes (post-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
@ -179,12 +192,13 @@ when there are no fields to compare.
## Common Pitfalls
- **Forgetting `MarkModified` after mutating a `ref` component.** Changes won't
propagate to R3 subscribers. Debug builds have a warning for this.
- **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 iteration (via `BeginIteration`/`EndIteration`). Adding/removing
components mid-iteration is safe — they're deferred.
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.
- **Entity ID 1 is the singleton.** Don't destroy it. Iterators skip it
automatically.
- **Singletons each have their own entity allocated automatically by
`SetSingleton<T>`.** Iterators skip singleton entities automatically.

View File

@ -58,7 +58,7 @@ public void PlaceBet_AdvancesToDealing()
Common test helpers:
- `SetupGame(seed?)` — create world, register systems, set initial singletons.
- `FindEntity<T>(world)` — find first non-singleton entity with component T.
- `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

View File

@ -97,12 +97,13 @@ target) are cleaned up automatically.
## Defining Systems
Systems implement `ISystem` (or `ITickedSystem` if they need delta time):
Systems implement `ISystem` (or `ITickedSystem` if they need delta time).
Implement `RunImpl` with the system logic:
```csharp
public class DealSystem : ISystem
{
public void Run(World world)
public void RunImpl(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Dealing)
@ -117,30 +118,54 @@ The `ISystem` interface has no `Query` property. Systems read singletons,
build queries, and iterate on their own — this keeps the interface minimal
and gives systems full flexibility.
### Iteration Styles
### Running Systems
Two options:
Use the `Run` extension method to execute a system with automatic batching:
**ForEach callbacks** (16 components):
```csharp
var query = world.Query().With<Position>().With<Velocity>().Build();
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) =>
{
pos.X += vel.X * dt;
world.MarkModified<Position>(e);
});
var system = new MySystem();
system.Run(world); // wraps RunImpl in BeginBatching/EndBatching
```
**Ref struct iterators** via `EntityIterator.Select<T>()` (13 components):
`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
using var iter = world.Select<PlayerHand>();
while (iter.MoveNext())
foreach (var it in world.Select<Position, Velocity>())
{
// iter.CurrentEntity, iter.Current1 (ref)
it.Ref1.X += it.Val2.X * dt; // Position tracked, Velocity not
it.Ref1.Y += it.Val2.Y * dt;
}
```
The singleton entity (ID 1) is automatically skipped by all iterators.
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
@ -155,8 +180,8 @@ group.Add(new PlayerBustCheckSystem());
group.Add(new DealerSystem());
```
`SystemGroup` automatically drains commands and posts changes after each system
and after the full tick.
`SystemGroup` automatically drains commands, flushes pending mutations, and
posts changes after each system and after the full tick.
## Defining Commands
@ -176,40 +201,46 @@ public record struct PlaceBetCommand : ICommand
state.CurrentBet = Amount;
state.Chips -= Amount;
state.Phase = GamePhase.Dealing;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
```
Enqueue commands via `world.Commands.Enqueue(...)`. They execute deferred
when the queue is drained (automatically by `SystemGroup`).
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
Global state lives on the singleton entity (ID 1). Use `SetSingleton<T>`,
`GetSingleton<T>` (ref), and `ReadSingleton<T>` (copy):
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:
// Mutation (auto-tracked during batching scopes):
ref var mutable = ref world.GetSingleton<GameState>();
mutable.Phase = GamePhase.RoundOver;
world.MarkModified<GameState>(World.SingletonEntity);
```
`GetSingleton` returns a `ref` — always call `MarkModified` after mutating
so reactivity subscribers see the change. `ReadSingleton` returns a copy and
never auto-marks.
`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 auto-marked.
- Value mutations (modifying a `ref T` component) must be manually marked via
`world.MarkModified<T>(entity)`.
- 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

View File

@ -19,6 +19,7 @@
<ItemGroup>
<ProjectReference Include="..\Game.Blackjack\Blackjack.csproj" />
<ProjectReference Include="..\OECS.PlayTest\OECS.PlayTest.csproj" />
</ItemGroup>
</Project>

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

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

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

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

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>();
}
}
}

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);
}
}

View File

@ -24,6 +24,5 @@ public struct PlaceBetCommand : ICommand
state.CurrentBet = Amount;
state.Chips -= Amount;
state.Phase = GamePhase.Dealing;
world.MarkModified<GameState>(World.SingletonEntity);
}
}

View File

@ -17,6 +17,5 @@ public struct StandCommand : ICommand
return;
state.Phase = GamePhase.DealerTurn;
world.MarkModified<GameState>(World.SingletonEntity);
}
}

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

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

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);
}
}

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);
}
}

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);
}
}

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;

View File

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

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

View File

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

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);
}
}

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);
}

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

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

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

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>

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);
}
}

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
});
}
}

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

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 { }

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

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

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

View File

@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for a faction deck entity.
/// </summary>
[MessagePackObject]
public struct FactionDeck { }

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

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 { }

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

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 { }

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

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>();
}
}

View File

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

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

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

View File

@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for the public deck entity.
/// </summary>
[MessagePackObject]
public struct PublicDeck { }

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
Game.CardWars/Enums.cs Normal file
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
}

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
Game.CardWars/GameUtil.cs Normal file
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 });
}
}
}

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

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

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

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);
}
}

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

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);
}
}

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);
}
}

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);
}
}

3
Game.CardWars/design.md Normal file
View File

@ -0,0 +1,3 @@
# CardWars — Design Document
<!-- Fill in the rules, mechanics, and design notes here. -->

View File

@ -19,6 +19,7 @@
<ItemGroup>
<ProjectReference Include="..\Game.TicTacToe\TicTacToe.csproj" />
<ProjectReference Include="..\OECS.PlayTest\OECS.PlayTest.csproj" />
</ItemGroup>
</Project>

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();
}
}
}
}

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

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();
}
}
}

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

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);
}
}

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);
}
}
}

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)];
}
}

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);
}

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

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>

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
OECS.PlayTest/PlayLog.cs Normal file
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;
}
}

View File

@ -44,6 +44,15 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
"GetSources",
};
private static readonly HashSet<string> _singletonMethods = new()
{
"SetSingleton",
"GetSingleton",
"ReadSingleton",
"HasSingleton",
"RemoveSingleton",
};
private static readonly HashSet<string> _forEachNames = new()
{
"ForEach",
@ -57,7 +66,7 @@ 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
@ -104,7 +113,7 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
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,7 +149,9 @@ 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(
@ -177,21 +188,29 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
}
private static void GenerateRegistry(SourceProductionContext context,
(ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> Left,
(ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship, bool IsSingleton)> Left,
ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> Right) data)
{
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);
{
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);
}
foreach (var t in forEachTypes)
{
if (!typeMap.ContainsKey(t.FullyQualifiedName))
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship);
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship, false);
else if (t.IsRelationship)
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, true);
{
var existing = typeMap[t.FullyQualifiedName];
typeMap[t.FullyQualifiedName] = (existing.Fqn, existing.Aqn, true, existing.IsSingleton);
}
}
if (typeMap.Count == 0)
@ -213,7 +232,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 +244,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(" )");
}

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++;
});
}
}
}
}

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,94 +385,16 @@ 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);
});
};
act.Should().NotThrow();
}
// ── QueryDescriptor.With enforced during ForEach ─────────────────
[Fact]
public void ForEach_Throws_WhenTypeParamsDivergeFromQueryWith()
{
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 });
// Build a query requiring Position + Velocity, but iterate only Position.
var query = world.Query().With<Position>().With<Velocity>().Build();
var act = () =>
{
world.ForEach(query, (Entity e, ref Position pos) => { });
};
act.Should().Throw<InvalidOperationException>()
.WithMessage("*ForEach*type*Position*Velocity*");
}
[Fact]
public void ForEach_Throws_WhenTypeParamsAreSubsetOfQueryWith()
{
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 });
// Build a query requiring 3 components, but iterate only 2.
var query = world.Query().With<Position>().With<Velocity>().With<Health>().Build();
var act = () =>
{
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => { });
};
act.Should().Throw<InvalidOperationException>()
.WithMessage("*ForEach*type*Health*");
}
[Fact]
public void ForEach_Throws_WhenTypeParamsAreSupersetOfQueryWith()
{
var world = new World();
var entity = world.CreateEntity();
world.AddComponent(entity, new Position { X = 1, Y = 2 });
// Build a query requiring only Position, but iterate Position + Velocity.
var query = world.Query().With<Position>().Build();
var act = () =>
{
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => { });
};
act.Should().Throw<InvalidOperationException>()
.WithMessage("*ForEach*type*Velocity*");
}
[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) => { });
world.DestroyEntity(it.Entity);
}
};
act.Should().NotThrow();
}

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();
}
}
}

View File

@ -208,7 +208,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());
@ -273,7 +273,7 @@ public class ReactivityTests
_world = world;
}
public void Run(World world)
public void RunImpl(World world)
{
_world.CreateEntity();
}

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,17 +183,16 @@ 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]);
}
@ -215,13 +208,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 +226,4 @@ public class RelationshipTests
world.HasComponent<Relationship<ChildOf, ParentOf>>(c).Should().BeFalse();
world.GetSources<Relationship<ChildOf, ParentOf>>(b).Should().BeEmpty();
}
}
}

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;

100
OECS.sln
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

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

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
}

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>

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>

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

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

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

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;

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>

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();
}
}

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);
}

64
OECS/OrderedEntitySet.cs Normal file
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
OECS/Query.cs Normal file
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;
}
}

View File

@ -1,39 +0,0 @@
namespace OECS;
/// <summary>
/// Fluent builder for constructing <see cref="QueryDescriptor"/> instances.
/// Returned by <see cref="World.Query"/>.
/// </summary>
public class QueryBuilder
{
private readonly HashSet<Type> _with = new();
private readonly HashSet<Type> _without = new();
/// <summary>
/// Requires entities to have a component of type <typeparamref name="T"/>.
/// </summary>
public QueryBuilder With<T>() where T : struct
{
_with.Add(typeof(T));
return this;
}
/// <summary>
/// Excludes entities that have a component of type <typeparamref name="T"/>.
/// </summary>
public QueryBuilder Without<T>() where T : struct
{
_without.Add(typeof(T));
return this;
}
/// <summary>
/// Builds the query descriptor.
/// </summary>
public QueryDescriptor Build()
{
return new QueryDescriptor(
new HashSet<Type>(_with),
new HashSet<Type>(_without));
}
}

View File

@ -1,53 +0,0 @@
namespace OECS;
/// <summary>
/// Describes a query over the ECS world.
/// Composed of a set of required component types ("with") and
/// a set of excluded component types ("without").
/// </summary>
public class QueryDescriptor
{
/// <summary>
/// Component types that must be present on matching entities.
/// </summary>
public IReadOnlySet<Type> With { get; }
/// <summary>
/// Component types that must NOT be present on matching entities.
/// </summary>
public IReadOnlySet<Type> Without { get; }
private readonly int _hashCode;
internal QueryDescriptor(HashSet<Type> with, HashSet<Type> without)
{
With = with;
Without = without;
_hashCode = ComputeHashCode();
}
/// <summary>
/// Returns true if this query has no "with" components (matches nothing).
/// </summary>
internal bool IsEmpty => With.Count == 0;
public override bool Equals(object? obj)
{
if (obj is not QueryDescriptor other) return false;
if (With.Count != other.With.Count || Without.Count != other.Without.Count)
return false;
return With.SetEquals(other.With) && Without.SetEquals(other.Without);
}
public override int GetHashCode() => _hashCode;
private int ComputeHashCode()
{
var hash = new HashCode();
foreach (var t in With.OrderBy(t => t.GUID.ToString()))
hash.Add(t);
foreach (var t in Without.OrderBy(t => t.GUID.ToString()))
hash.Add(t);
return hash.ToHashCode();
}
}

View File

@ -1,507 +0,0 @@
using System.Runtime.CompilerServices;
namespace OECS;
/// <summary>
/// Provides query execution logic for <see cref="World"/>.
/// Iterates one sparse set as the driver and probes the remaining sets
/// for membership. The "without" filter is checked after all "with" probes.
///
/// The singleton entity (ID 1) is automatically excluded from all queries.
/// </summary>
internal static class QueryExecutor
{
private const uint SingletonId = 1;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool PassesWithoutFilter(ComponentStore store, Entity entity, IReadOnlySet<Type> withoutTypes)
{
foreach (var type in withoutTypes)
{
var set = store.GetSet(type);
if (set != null && set.Contains(entity))
return false;
}
return true;
}
// ── ForEach overloads ──────────────────────────────────────────────
public static void ForEach<T1>(
ComponentStore store,
QueryDescriptor query,
ForEachAction<T1> action)
where T1 : struct
{
var set = store.GetSet(typeof(T1)) as SparseSet<T1>;
if (set == null) return;
var dense = set.Dense;
var denseEntities = set.DenseEntities;
var count = set.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!PassesWithoutFilter(store, entity, query.Without))
continue;
action(entity, ref dense[i]);
}
}
public static void ForEach<T1, T2>(
ComponentStore store,
QueryDescriptor query,
ForEachAction<T1, T2> action)
where T1 : struct where T2 : struct
{
var set1 = store.GetSet(typeof(T1)) as SparseSet<T1>;
if (set1 == null) return;
var set2 = store.GetSet(typeof(T2)) as SparseSet<T2>;
if (set2 == null) return;
if (set1.Count <= set2.Count)
{
IterateTwo(set1, set2, store, query.Without, action);
}
else
{
IterateTwoSwapped(set2, set1, store, query.Without, action);
}
}
private static void IterateTwo<TDriver, TOther>(
SparseSet<TDriver> driveSet,
SparseSet<TOther> otherSet,
ComponentStore store,
IReadOnlySet<Type> withoutTypes,
ForEachAction<TDriver, TOther> action)
where TDriver : struct where TOther : struct
{
var dense = driveSet.Dense;
var denseEntities = driveSet.DenseEntities;
var count = driveSet.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!otherSet.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, withoutTypes))
continue;
action(entity, ref dense[i], ref otherSet.Get(entity));
}
}
private static void IterateTwoSwapped<TOther, TDriver>(
SparseSet<TDriver> driveSet,
SparseSet<TOther> otherSet,
ComponentStore store,
IReadOnlySet<Type> withoutTypes,
ForEachAction<TOther, TDriver> action)
where TDriver : struct where TOther : struct
{
var dense = driveSet.Dense;
var denseEntities = driveSet.DenseEntities;
var count = driveSet.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!otherSet.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, withoutTypes))
continue;
action(entity, ref otherSet.Get(entity), ref dense[i]);
}
}
public static void ForEach<T1, T2, T3>(
ComponentStore store,
QueryDescriptor query,
ForEachAction<T1, T2, T3> action)
where T1 : struct where T2 : struct where T3 : struct
{
var set1 = store.GetSet(typeof(T1)) as SparseSet<T1>;
if (set1 == null) return;
var set2 = store.GetSet(typeof(T2)) as SparseSet<T2>;
if (set2 == null) return;
var set3 = store.GetSet(typeof(T3)) as SparseSet<T3>;
if (set3 == null) return;
// Pick the smallest set as the driver.
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count;
if (c2 <= c1 && c2 <= c3)
{
var dense = set2.Dense;
var denseEntities = set2.DenseEntities;
var count = set2.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity));
}
}
else if (c3 <= c1 && c3 <= c2)
{
var dense = set3.Dense;
var denseEntities = set3.DenseEntities;
var count = set3.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i]);
}
}
else
{
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity));
}
}
}
public static void ForEach<T1, T2, T3, T4>(
ComponentStore store,
QueryDescriptor query,
ForEachAction<T1, T2, T3, T4> action)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct
{
var set1 = store.GetSet(typeof(T1)) as SparseSet<T1>;
if (set1 == null) return;
var set2 = store.GetSet(typeof(T2)) as SparseSet<T2>;
if (set2 == null) return;
var set3 = store.GetSet(typeof(T3)) as SparseSet<T3>;
if (set3 == null) return;
var set4 = store.GetSet(typeof(T4)) as SparseSet<T4>;
if (set4 == null) return;
// Pick the smallest set as the driver.
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count, c4 = set4.Count;
int min = Math.Min(Math.Min(c1, c2), Math.Min(c3, c4));
if (min == c2)
{
var dense = set2.Dense;
var denseEntities = set2.DenseEntities;
var count = set2.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity), ref set4.Get(entity));
}
}
else if (min == c3)
{
var dense = set3.Dense;
var denseEntities = set3.DenseEntities;
var count = set3.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i], ref set4.Get(entity));
}
}
else if (min == c4)
{
var dense = set4.Dense;
var denseEntities = set4.DenseEntities;
var count = set4.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref dense[i]);
}
}
else
{
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity));
}
}
}
public static void ForEach<T1, T2, T3, T4, T5>(
ComponentStore store,
QueryDescriptor query,
ForEachAction<T1, T2, T3, T4, T5> action)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct
{
var set1 = store.GetSet(typeof(T1)) as SparseSet<T1>;
if (set1 == null) return;
var set2 = store.GetSet(typeof(T2)) as SparseSet<T2>;
if (set2 == null) return;
var set3 = store.GetSet(typeof(T3)) as SparseSet<T3>;
if (set3 == null) return;
var set4 = store.GetSet(typeof(T4)) as SparseSet<T4>;
if (set4 == null) return;
var set5 = store.GetSet(typeof(T5)) as SparseSet<T5>;
if (set5 == null) return;
// Pick the smallest set as the driver.
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count, c4 = set4.Count, c5 = set5.Count;
int min = Math.Min(Math.Min(Math.Min(c1, c2), Math.Min(c3, c4)), c5);
if (min == c2)
{
var dense = set2.Dense;
var denseEntities = set2.DenseEntities;
var count = set2.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity));
}
}
else if (min == c3)
{
var dense = set3.Dense;
var denseEntities = set3.DenseEntities;
var count = set3.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i], ref set4.Get(entity), ref set5.Get(entity));
}
}
else if (min == c4)
{
var dense = set4.Dense;
var denseEntities = set4.DenseEntities;
var count = set4.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref dense[i], ref set5.Get(entity));
}
}
else if (min == c5)
{
var dense = set5.Dense;
var denseEntities = set5.DenseEntities;
var count = set5.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref dense[i]);
}
}
else
{
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity));
}
}
}
public static void ForEach<T1, T2, T3, T4, T5, T6>(
ComponentStore store,
QueryDescriptor query,
ForEachAction<T1, T2, T3, T4, T5, T6> action)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct
{
var set1 = store.GetSet(typeof(T1)) as SparseSet<T1>;
if (set1 == null) return;
var set2 = store.GetSet(typeof(T2)) as SparseSet<T2>;
if (set2 == null) return;
var set3 = store.GetSet(typeof(T3)) as SparseSet<T3>;
if (set3 == null) return;
var set4 = store.GetSet(typeof(T4)) as SparseSet<T4>;
if (set4 == null) return;
var set5 = store.GetSet(typeof(T5)) as SparseSet<T5>;
if (set5 == null) return;
var set6 = store.GetSet(typeof(T6)) as SparseSet<T6>;
if (set6 == null) return;
// Pick the smallest set as the driver.
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count, c4 = set4.Count, c5 = set5.Count, c6 = set6.Count;
int min = Math.Min(Math.Min(Math.Min(c1, c2), Math.Min(c3, c4)), Math.Min(c5, c6));
if (min == c2)
{
var dense = set2.Dense;
var denseEntities = set2.DenseEntities;
var count = set2.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity), ref set6.Get(entity));
}
}
else if (min == c3)
{
var dense = set3.Dense;
var denseEntities = set3.DenseEntities;
var count = set3.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i], ref set4.Get(entity), ref set5.Get(entity), ref set6.Get(entity));
}
}
else if (min == c4)
{
var dense = set4.Dense;
var denseEntities = set4.DenseEntities;
var count = set4.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref dense[i], ref set5.Get(entity), ref set6.Get(entity));
}
}
else if (min == c5)
{
var dense = set5.Dense;
var denseEntities = set5.DenseEntities;
var count = set5.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref dense[i], ref set6.Get(entity));
}
}
else if (min == c6)
{
var dense = set6.Dense;
var denseEntities = set6.DenseEntities;
var count = set6.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set1.Contains(entity)) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity), ref dense[i]);
}
}
else
{
var dense = set1.Dense;
var denseEntities = set1.DenseEntities;
var count = set1.Count;
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
if (!set5.Contains(entity)) continue;
if (!set6.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity), ref set6.Get(entity));
}
}
}
}

View File

@ -14,7 +14,6 @@ namespace OECS;
/// // Define a "ChildOf" relationship between a child and a parent entity.
/// world.AddComponent(child, new Relationship&lt;ChildOf, Parent&gt;
/// {
/// Source = child,
/// Target = parent
/// });
///
@ -27,15 +26,9 @@ namespace OECS;
public struct Relationship<TSelf, TTarget> : IRelationship
where TSelf : struct where TTarget : struct
{
/// <summary>
/// The entity that owns this relationship component.
/// </summary>
[Key(0)]
public Entity Source { get; set; }
/// <summary>
/// The entity that this relationship points to.
/// </summary>
[Key(1)]
[Key(0)]
public Entity Target { get; set; }
}

View File

@ -8,13 +8,16 @@ namespace OECS;
///
/// Updated automatically by <see cref="World"/> when relationship
/// components are added, removed, or when entities are destroyed.
///
/// Source sets are insertion-ordered — the order in which relationships
/// are added is preserved during iteration.
/// </summary>
internal class RelationshipIndex
{
/// <summary>
/// Per relationship type: target entity → set of source entities.
/// </summary>
private readonly Dictionary<Type, Dictionary<Entity, HashSet<Entity>>> _index = new();
private readonly Dictionary<Type, Dictionary<Entity, OrderedEntitySet>> _index = new();
/// <summary>
/// Registers that a source entity now has a relationship pointing to a target.
@ -23,13 +26,13 @@ internal class RelationshipIndex
{
if (!_index.TryGetValue(relationshipType, out var targetMap))
{
targetMap = new Dictionary<Entity, HashSet<Entity>>();
targetMap = new Dictionary<Entity, OrderedEntitySet>();
_index[relationshipType] = targetMap;
}
if (!targetMap.TryGetValue(target, out var sources))
{
sources = new HashSet<Entity>();
sources = new OrderedEntitySet();
targetMap[target] = sources;
}
@ -55,7 +58,7 @@ internal class RelationshipIndex
/// <summary>
/// Returns all source entities that have a relationship of the given type
/// pointing to the specified target entity.
/// pointing to the specified target entity, in insertion order.
/// </summary>
public IReadOnlyCollection<Entity> GetSources<T>(Entity target)
where T : struct, IRelationship
@ -116,6 +119,23 @@ internal class RelationshipIndex
}
}
/// <summary>
/// Reorders the source set for the given relationship type and target
/// to match the order of entities in <paramref name="ordered"/>.
/// </summary>
public void ReorderSources<T>(Entity target, IReadOnlyList<Entity> ordered)
where T : struct, IRelationship
{
var type = typeof(T);
if (!_index.TryGetValue(type, out var targetMap))
return;
if (!targetMap.TryGetValue(target, out var sources))
return;
sources.Reorder(ordered);
}
/// <summary>
/// Returns true if the index has any entries for the given relationship type.
/// </summary>
@ -123,4 +143,4 @@ internal class RelationshipIndex
{
return _index.ContainsKey(relationshipType);
}
}
}

View File

@ -62,6 +62,7 @@ public class SystemGroup
// sees their effects.
_world.ExecuteCommands();
_world.BeginBatching();
foreach (var system in _systems)
{
if (system is ITickedSystem ticked)
@ -77,10 +78,15 @@ public class SystemGroup
// see the effects of commands enqueued by prior systems.
_world.ExecuteCommands();
// Flush pending mutations so subsequent systems see
// entity/component changes from commands and previous systems.
_world.FlushPendingMutations();
// Post changes after each system so subscribers see
// incremental updates.
_world.PostChanges();
}
_world.EndBatching();
// Drain any remaining commands (e.g., those enqueued outside systems).
_world.ExecuteCommands();

View File

@ -11,23 +11,19 @@ namespace OECS;
/// </summary>
public class World : IDisposable
{
/// <summary>
/// The reserved entity ID for the singleton entity.
/// </summary>
public static readonly Entity SingletonEntity = new(1, 1);
private readonly EntityAllocator _allocator;
private readonly ComponentStore _components;
private readonly CommandQueue _commands;
private readonly RelationshipIndex _relationships;
private readonly ChangeBuffer _changes;
private bool _singletonCreated;
private readonly Dictionary<Type, Entity> _singletonEntities = new();
private bool _disposed;
// Deferred structural mutation support: when iterating entities,
// AddComponent, RemoveComponent, and DestroyEntity are buffered and
// applied after the iteration completes.
private int _iterationDepth;
// Deferred structural mutation support: when inside a batching scope
// (e.g., a foreach iteration or a system run), AddComponent,
// RemoveComponent, and DestroyEntity are buffered and applied when
// the outermost scope ends.
private int _batchingDepth;
private readonly List<PendingMutation> _pendingMutations = new();
private enum PendingMutationKind { AddComponent, RemoveComponent, DestroyEntity }
@ -40,10 +36,10 @@ public class World : IDisposable
public Type ComponentType;
}
// Auto-dirty-marking: during iteration, component accesses via
// GetComponent<T> are tracked. When the iteration scope ends,
// all accessed components are automatically marked as modified.
// Outside of iteration, MarkModified<T> must be called explicitly.
// Auto-dirty-marking: during a batching scope, component accesses via
// GetComponent<T> are tracked. When the scope ends, all accessed
// components are automatically marked as modified.
// Outside of a batching scope, MarkModified<T> must be called explicitly.
private readonly HashSet<(Entity Entity, Type ComponentType)> _accessedComponents = new();
private readonly HashSet<(Entity Entity, Type ComponentType)> _markedModified = new();
@ -91,7 +87,7 @@ public class World : IDisposable
/// </summary>
public void DestroyEntity(Entity entity)
{
if (_iterationDepth > 0)
if (_batchingDepth > 0)
{
_pendingMutations.Add(new PendingMutation
{
@ -109,10 +105,6 @@ public class World : IDisposable
if (!_allocator.IsAlive(entity))
return;
// Singleton entity is never destroyed.
if (entity.Id == 1)
return;
// Remove all relationships where this entity is the target.
// Materialize to avoid collection-modified-during-enumeration when
// OnRemoved mutates the same HashSet we're iterating.
@ -166,7 +158,7 @@ public class World : IDisposable
/// </summary>
public void AddComponent<T>(Entity entity, T component) where T : struct
{
if (_iterationDepth > 0)
if (_batchingDepth > 0)
{
_pendingMutations.Add(new PendingMutation
{
@ -203,19 +195,8 @@ public class World : IDisposable
ThrowIfNotAlive(entity);
// If replacing an existing relationship, remove the old index entry first.
if (component is IRelationship newRel)
if (component is IRelationship)
{
// Guard against mismatched Source: the relationship must be stored
// on the entity it claims as its Source, otherwise the reverse
// index becomes corrupted.
if (newRel.Source != entity)
{
throw new InvalidOperationException(
$"Relationship of type {typeof(T).Name} has Source={newRel.Source} " +
$"but is being added to entity {entity}. " +
$"The Source must match the entity the component is added to.");
}
if (_components.TryGet<T>(entity, out var old))
{
var oldRel = (IRelationship)(object)old;
@ -246,7 +227,7 @@ public class World : IDisposable
/// </summary>
public void RemoveComponent<T>(Entity entity) where T : struct
{
if (_iterationDepth > 0)
if (_batchingDepth > 0)
{
_pendingMutations.Add(new PendingMutation
{
@ -289,11 +270,22 @@ public class World : IDisposable
/// </summary>
public ref T GetComponent<T>(Entity entity) where T : struct
{
if (_iterationDepth > 0)
if (_batchingDepth > 0)
_accessedComponents.Add((entity, typeof(T)));
return ref _components.Get<T>(entity);
}
/// <summary>
/// Tracks a component access for auto-dirty-marking without returning
/// a ref. Used by <see cref="SelectN"/> iterators when they expose
/// component refs directly from the sparse set.
/// </summary>
internal void TrackAccess(Entity entity, Type componentType)
{
if (_batchingDepth > 0)
_accessedComponents.Add((entity, componentType));
}
/// <summary>
/// Returns a copy of the component of type <typeparamref name="T"/>
/// for the given entity. Never auto-marks as modified — use this when
@ -327,9 +319,9 @@ public class World : IDisposable
/// <summary>
/// Marks a component of type <typeparamref name="T"/> on the given entity
/// as modified. Only needed outside of iteration scopes — during
/// <see cref="ForEach"/> or a <c>Select</c> loop, components accessed
/// via <see cref="GetComponent{T}"/> are auto-marked.
/// as modified. Only needed outside of iteration scopes — during a
/// <c>Select</c> loop, components accessed via <see cref="GetComponent{T}"/>
/// are auto-marked.
/// </summary>
public void MarkModified<T>(Entity entity) where T : struct
{
@ -366,11 +358,21 @@ public class World : IDisposable
}
/// <summary>
/// Returns an observable that emits changes matching the given query.
/// Only component-level changes whose component type is in the query's
/// "with" set and not in the "without" set are emitted.
/// 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 = default)
where T1 : struct
{
return _changes.ObserveQuery(query);
}
/// <summary>
/// Returns an observable that emits component-level changes matching
/// the given query.
/// </summary>
public Observable<EntityChange> ObserveQuery<T1, T2>(Query<T1, T2> query = default)
where T1 : struct where T2 : struct
{
return _changes.ObserveQuery(query);
}
@ -379,13 +381,16 @@ public class World : IDisposable
/// <summary>
/// Sets (adds or replaces) a singleton component of type <typeparamref name="T"/>.
/// Singletons are stored on a reserved entity that is never destroyed
/// and excluded from normal queries.
/// Each singleton component type gets its own dedicated entity.
/// </summary>
public void SetSingleton<T>(T component) where T : struct
{
EnsureSingleton();
AddComponent(SingletonEntity, component);
if (!_singletonEntities.TryGetValue(typeof(T), out var entity))
{
entity = _allocator.Allocate();
_singletonEntities[typeof(T)] = entity;
}
AddComponent(entity, component);
}
/// <summary>
@ -394,8 +399,7 @@ public class World : IDisposable
/// </summary>
public ref T GetSingleton<T>() where T : struct
{
EnsureSingleton();
return ref GetComponent<T>(SingletonEntity);
return ref GetComponent<T>(GetSingletonEntity<T>());
}
/// <summary>
@ -404,8 +408,7 @@ public class World : IDisposable
/// </summary>
public T ReadSingleton<T>() where T : struct
{
EnsureSingleton();
return ReadComponent<T>(SingletonEntity);
return ReadComponent<T>(GetSingletonEntity<T>());
}
/// <summary>
@ -413,7 +416,8 @@ public class World : IDisposable
/// </summary>
public bool HasSingleton<T>() where T : struct
{
return HasComponent<T>(SingletonEntity);
return _singletonEntities.TryGetValue(typeof(T), out var entity)
&& HasComponent<T>(entity);
}
/// <summary>
@ -422,20 +426,22 @@ public class World : IDisposable
/// </summary>
public void RemoveSingleton<T>() where T : struct
{
RemoveComponent<T>(SingletonEntity);
if (_singletonEntities.TryGetValue(typeof(T), out var entity))
RemoveComponent<T>(entity);
}
internal void EnsureSingleton()
/// <summary>
/// Gets the entity backing the singleton of type <typeparamref name="T"/>,
/// creating it if it doesn't exist yet.
/// </summary>
private Entity GetSingletonEntity<T>() where T : struct
{
if (_singletonCreated)
return;
_singletonCreated = true;
// Manually register the singleton entity in the allocator so
// IsAlive returns true for it. We bypass Allocate() because
// the singleton has a fixed ID and version.
_allocator.RegisterSingleton(SingletonEntity);
if (!_singletonEntities.TryGetValue(typeof(T), out var entity))
{
entity = _allocator.Allocate();
_singletonEntities[typeof(T)] = entity;
}
return entity;
}
// ── Relationships ────────────────────────────────────────────────
@ -443,6 +449,7 @@ public class World : IDisposable
/// <summary>
/// Returns all source entities that have a relationship of type
/// <typeparamref name="T"/> pointing to the given target entity.
/// Sources are iterated in insertion order.
/// </summary>
public IReadOnlyCollection<Entity> GetSources<T>(Entity target)
where T : struct, IRelationship
@ -450,6 +457,21 @@ public class World : IDisposable
return _relationships.GetSources<T>(target);
}
/// <summary>
/// Reorders the source set for the given relationship type and target
/// to match the order of entities in <paramref name="ordered"/>.
/// All entities currently in the set must appear exactly once in the
/// provided collection; no entities are added or removed.
/// Fires a <see cref="ChangeKind.RelationshipReordered"/> change event
/// on the target entity.
/// </summary>
public void ReorderSources<T>(Entity target, IReadOnlyList<Entity> ordered)
where T : struct, IRelationship
{
_relationships.ReorderSources<T>(target, ordered);
_changes.Pending.MarkRelationshipReordered(target, typeof(T));
}
// ── Commands ──────────────────────────────────────────────────────
/// <summary>
@ -468,159 +490,28 @@ public class World : IDisposable
_commands.ExecuteAll(this);
}
// ── Queries ──────────────────────────────────────────────────────
// ── Iteration Lifecycle ──────────────────────────────────────────
/// <summary>
/// Returns a new <see cref="QueryBuilder"/> for constructing queries.
/// Begins a batching scope. Structural mutations (AddComponent,
/// RemoveComponent, DestroyEntity) are buffered until the outermost
/// <see cref="EndBatching"/> call. Component accesses via
/// <see cref="GetComponent{T}"/> are auto-tracked for modification.
/// </summary>
public QueryBuilder Query() => new();
/// <summary>
/// Iterates all entities matching the query, providing ref access to
/// one component type. Structural mutations (AddComponent, RemoveComponent,
/// DestroyEntity) made during iteration are deferred and applied after
/// the iteration completes.
/// </summary>
public void ForEach<T1>(
QueryDescriptor query,
ForEachAction<T1> action)
where T1 : struct
internal void BeginBatching()
{
ValidateQueryTypes(query, typeof(T1));
BeginIteration();
try
{
QueryExecutor.ForEach(_components, query, action);
}
finally
{
EndIteration();
}
_batchingDepth++;
}
/// <summary>
/// Iterates all entities matching the query, providing ref access to
/// two component types. Structural mutations are deferred.
/// </summary>
public void ForEach<T1, T2>(
QueryDescriptor query,
ForEachAction<T1, T2> action)
where T1 : struct where T2 : struct
{
ValidateQueryTypes(query, typeof(T1), typeof(T2));
BeginIteration();
try
{
QueryExecutor.ForEach(_components, query, action);
}
finally
{
EndIteration();
}
}
/// <summary>
/// Iterates all entities matching the query, providing ref access to
/// three component types. Structural mutations are deferred.
/// </summary>
public void ForEach<T1, T2, T3>(
QueryDescriptor query,
ForEachAction<T1, T2, T3> action)
where T1 : struct where T2 : struct where T3 : struct
{
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3));
BeginIteration();
try
{
QueryExecutor.ForEach(_components, query, action);
}
finally
{
EndIteration();
}
}
/// <summary>
/// Iterates all entities matching the query, providing ref access to
/// four component types. Structural mutations are deferred.
/// </summary>
public void ForEach<T1, T2, T3, T4>(
QueryDescriptor query,
ForEachAction<T1, T2, T3, T4> action)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct
{
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3), typeof(T4));
BeginIteration();
try
{
QueryExecutor.ForEach(_components, query, action);
}
finally
{
EndIteration();
}
}
/// <summary>
/// Iterates all entities matching the query, providing ref access to
/// five component types. Structural mutations are deferred.
/// </summary>
public void ForEach<T1, T2, T3, T4, T5>(
QueryDescriptor query,
ForEachAction<T1, T2, T3, T4, T5> action)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct
{
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3), typeof(T4), typeof(T5));
BeginIteration();
try
{
QueryExecutor.ForEach(_components, query, action);
}
finally
{
EndIteration();
}
}
/// <summary>
/// Iterates all entities matching the query, providing ref access to
/// six component types. Structural mutations are deferred.
/// </summary>
public void ForEach<T1, T2, T3, T4, T5, T6>(
QueryDescriptor query,
ForEachAction<T1, T2, T3, T4, T5, T6> action)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct
{
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3), typeof(T4), typeof(T5), typeof(T6));
BeginIteration();
try
{
QueryExecutor.ForEach(_components, query, action);
}
finally
{
EndIteration();
}
}
/// <summary>
/// Begins an iteration scope. Structural mutations are buffered
/// until <see cref="EndIteration"/> is called.
/// </summary>
internal void BeginIteration()
{
_iterationDepth++;
}
/// <summary>
/// Ends an iteration scope. When the outermost scope ends, all
/// Ends a batching scope. When the outermost scope ends, all
/// buffered structural mutations are applied and auto-tracked
/// component modifications are posted.
/// </summary>
internal void EndIteration()
internal void EndBatching()
{
_iterationDepth--;
if (_iterationDepth == 0)
_batchingDepth--;
if (_batchingDepth == 0)
{
// Auto-mark all components accessed via GetComponent<T>
// during the iteration as modified.
@ -638,7 +529,7 @@ public class World : IDisposable
}
}
private void FlushPendingMutations()
public void FlushPendingMutations()
{
if (_pendingMutations.Count == 0)
return;
@ -681,6 +572,33 @@ public class World : IDisposable
/// </summary>
internal ComponentStore Components => _components;
/// <summary>
/// Gets the entity that backs the singleton of type <typeparamref name="T"/>,
/// or <see cref="Entity.Null"/> if the singleton has not been set.
/// </summary>
internal Entity GetSingletonEntityOrNull<T>() where T : struct
{
_singletonEntities.TryGetValue(typeof(T), out var entity);
return entity;
}
/// <summary>
/// Registers an existing entity as the backing entity for a singleton component.
/// Used during deserialization to restore singleton state.
/// </summary>
internal void RegisterSingletonEntity(Type componentType, Entity entity)
{
_singletonEntities[componentType] = entity;
}
/// <summary>
/// Returns true if the given entity is a singleton entity (backs any singleton component).
/// </summary>
internal bool IsSingletonEntity(Entity entity)
{
return _singletonEntities.ContainsValue(entity);
}
// ── Helpers ───────────────────────────────────────────────────────
private void ThrowIfNotAlive(Entity entity)
@ -689,39 +607,6 @@ public class World : IDisposable
throw new InvalidOperationException($"Entity {entity} is not alive.");
}
/// <summary>
/// Validates that the ForEach type parameters match the query's
/// With set. When the With set is empty (only Without filters are
/// specified), any ForEach type parameters are accepted.
/// </summary>
private static void ValidateQueryTypes(QueryDescriptor query, params Type[] forEachTypes)
{
// When With is empty, the query is using only Without filters.
// The ForEach type parameters are the sole source of truth.
if (query.With.Count == 0)
return;
if (query.With.Count != forEachTypes.Length)
ThrowMismatch(query, forEachTypes);
foreach (var t in forEachTypes)
{
if (!query.With.Contains(t))
ThrowMismatch(query, forEachTypes);
}
}
private static void ThrowMismatch(QueryDescriptor query, Type[] forEachTypes)
{
var queryTypes = string.Join(", ", query.With.Select(t => t.Name));
var forEachTypeNames = string.Join(", ", forEachTypes.Select(t => t.Name));
throw new InvalidOperationException(
$"ForEach type parameters [{forEachTypeNames}] do not match " +
$"the query's With types [{queryTypes}]. " +
$"Ensure the ForEach type parameters exactly match the types " +
$"passed to QueryBuilder.With<T>().");
}
// ── Cleanup ───────────────────────────────────────────────────────
public void Dispose()
@ -731,4 +616,4 @@ public class World : IDisposable
_disposed = true;
_changes.Dispose();
}
}
}

View File

@ -0,0 +1,719 @@
using System.Runtime.CompilerServices;
namespace OECS;
/// <summary>
/// Provides zero-allocation ref struct iterators and entity-finding
/// extension methods for querying a <see cref="World"/>.
/// </summary>
public static class WorldQueryExtensions
{
// ── Select (ref struct enumerators) ──────────────────────────────
public static Select1<T1> Select<T1>(this World world, Query<T1> query = default)
where T1 : struct
{
return new Select1<T1>(world, query);
}
public static Select2<T1, T2> Select<T1, T2>(this World world, Query<T1, T2> query = default)
where T1 : struct where T2 : struct
{
return new Select2<T1, T2>(world, query);
}
public static Select3<T1, T2, T3> Select<T1, T2, T3>(this World world, Query<T1, T2, T3> query = default)
where T1 : struct where T2 : struct where T3 : struct
{
return new Select3<T1, T2, T3>(world, query);
}
public static Select4<T1, T2, T3, T4> Select<T1, T2, T3, T4>(this World world, Query<T1, T2, T3, T4> query = default)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct
{
return new Select4<T1, T2, T3, T4>(world, query);
}
public static Select5<T1, T2, T3, T4, T5> Select<T1, T2, T3, T4, T5>(this World world, Query<T1, T2, T3, T4, T5> query = default)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct
{
return new Select5<T1, T2, T3, T4, T5>(world, query);
}
public static Select6<T1, T2, T3, T4, T5, T6> Select<T1, T2, T3, T4, T5, T6>(this World world, Query<T1, T2, T3, T4, T5, T6> query = default)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct
{
return new Select6<T1, T2, T3, T4, T5, T6>(world, query);
}
// ── FindEntity ──────────────────────────────────────────────────
public static Entity FindEntity<T1>(this World world, Query<T1> query = default)
where T1 : struct
{
using var iter = new Select1<T1>(world, query);
return iter.MoveNext() ? iter.Entity : Entity.Null;
}
public static Entity FindEntity<T1, T2>(this World world, Query<T1, T2> query = default)
where T1 : struct where T2 : struct
{
using var iter = new Select2<T1, T2>(world, query);
return iter.MoveNext() ? iter.Entity : Entity.Null;
}
public static Entity FindEntity<T1, T2, T3>(this World world, Query<T1, T2, T3> query = default)
where T1 : struct where T2 : struct where T3 : struct
{
using var iter = new Select3<T1, T2, T3>(world, query);
return iter.MoveNext() ? iter.Entity : Entity.Null;
}
}
// ── Ref struct enumerators ───────────────────────────────────────────
/// <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 HashSet<Type>? _without;
private int _index;
private readonly int _count;
internal Select1(World world, Query<T1> query)
{
_world = world;
_store = world.Components;
_without = query.WithoutTypes;
_set = _store.GetSet(typeof(T1)) as SparseSet<T1>;
_count = _set?.Count ?? 0;
_index = -1;
_world.BeginBatching();
}
/// <summary>The current entity handle.</summary>
public Entity Entity { get; private set; }
/// <summary>The current enumerator (required for foreach duck-typing).</summary>
public Select1<T1> Current => this;
/// <summary>
/// Mutable reference to the current entity's component of type <typeparamref name="T1"/>.
/// Access is tracked for auto-dirty-marking during a batching scope.
/// </summary>
public ref T1 Ref1
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get
{
_world.TrackAccess(Entity, typeof(T1));
return ref _set!.Get(Entity);
}
}
/// <summary>
/// Read-only reference to the current entity's component of type <typeparamref name="T1"/>.
/// Access is NOT tracked for auto-dirty-marking.
/// </summary>
public ref readonly T1 Val1 => ref _set!.Get(Entity);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool MoveNext()
{
if (_set == null) return false;
while (++_index < _count)
{
Entity = _set.DenseEntities[_index];
if (!PassesWithout()) continue;
if (_world.IsSingletonEntity(Entity)) continue;
return true;
}
return false;
}
public Select1<T1> GetEnumerator() => this;
public void Dispose()
{
_world.EndBatching();
}
private bool PassesWithout()
{
if (_without == null) return true;
foreach (var type in _without)
{
var set = _store.GetSet(type);
if (set != null && set.Contains(Entity))
return false;
}
return true;
}
}
/// <summary>Ref struct iterator for queries with two component types. Drives from the smaller set.</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 HashSet<Type>? _without;
private int _index;
private readonly int _count;
private readonly bool _swapped;
internal Select2(World world, Query<T1, T2> query)
{
_world = world;
_store = world.Components;
_without = query.WithoutTypes;
_set1 = _store.GetSet(typeof(T1)) as SparseSet<T1>;
_set2 = _store.GetSet(typeof(T2)) as SparseSet<T2>;
int c1 = _set1?.Count ?? 0;
int c2 = _set2?.Count ?? 0;
_swapped = c2 < c1;
_count = _swapped ? c2 : c1;
_index = -1;
_world.BeginBatching();
}
public Entity Entity { get; private set; }
public Select2<T1, T2> Current => this;
public ref T1 Ref1
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get
{
_world.TrackAccess(Entity, typeof(T1));
return ref _set1!.Get(Entity);
}
}
public ref T2 Ref2
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get
{
_world.TrackAccess(Entity, typeof(T2));
return ref _set2!.Get(Entity);
}
}
public ref readonly T1 Val1 => ref _set1!.Get(Entity);
public ref readonly T2 Val2 => ref _set2!.Get(Entity);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool MoveNext()
{
if (_set1 == null || _set2 == null) return false;
while (++_index < _count)
{
if (_swapped)
{
Entity = _set2!.DenseEntities[_index];
if (!_set1.Contains(Entity)) continue;
}
else
{
Entity = _set1.DenseEntities[_index];
if (!_set2.Contains(Entity)) continue;
}
if (!PassesWithout()) continue;
if (_world.IsSingletonEntity(Entity)) continue;
return true;
}
return false;
}
public Select2<T1, T2> GetEnumerator() => this;
public void Dispose() => _world.EndBatching();
private bool PassesWithout()
{
if (_without == null) return true;
foreach (var type in _without)
{
var set = _store.GetSet(type);
if (set != null && set.Contains(Entity))
return false;
}
return true;
}
}
/// <summary>Ref struct iterator for queries with three component types. Drives from the smallest set.</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 HashSet<Type>? _without;
private int _index;
private readonly int _count;
private readonly int _driver; // 0 = set1, 1 = set2, 2 = set3
internal Select3(World world, Query<T1, T2, T3> query)
{
_world = world;
_store = world.Components;
_without = query.WithoutTypes;
_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 c1 = _set1?.Count ?? 0;
int c2 = _set2?.Count ?? 0;
int c3 = _set3?.Count ?? 0;
if (c2 <= c1 && c2 <= c3) { _driver = 1; _count = c2; }
else if (c3 <= c1 && c3 <= c2) { _driver = 2; _count = c3; }
else { _driver = 0; _count = c1; }
_index = -1;
_world.BeginBatching();
}
public Entity Entity { get; private set; }
public Select3<T1, T2, T3> Current => this;
public ref T1 Ref1 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T1)); return ref _set1!.Get(Entity); } }
public ref T2 Ref2 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T2)); return ref _set2!.Get(Entity); } }
public ref T3 Ref3 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T3)); return ref _set3!.Get(Entity); } }
public ref readonly T1 Val1 => ref _set1!.Get(Entity);
public ref readonly T2 Val2 => ref _set2!.Get(Entity);
public ref readonly T3 Val3 => ref _set3!.Get(Entity);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool MoveNext()
{
if (_set1 == null || _set2 == null || _set3 == null) return false;
while (++_index < _count)
{
switch (_driver)
{
case 0:
Entity = _set1.DenseEntities[_index];
if (!_set2.Contains(Entity)) continue;
if (!_set3.Contains(Entity)) continue;
break;
case 1:
Entity = _set2!.DenseEntities[_index];
if (!_set1.Contains(Entity)) continue;
if (!_set3.Contains(Entity)) continue;
break;
case 2:
Entity = _set3!.DenseEntities[_index];
if (!_set1.Contains(Entity)) continue;
if (!_set2.Contains(Entity)) continue;
break;
}
if (!PassesWithout()) continue;
if (_world.IsSingletonEntity(Entity)) continue;
return true;
}
return false;
}
public Select3<T1, T2, T3> GetEnumerator() => this;
public void Dispose() => _world.EndBatching();
private bool PassesWithout()
{
if (_without == null) return true;
foreach (var type in _without)
{
var set = _store.GetSet(type);
if (set != null && set.Contains(Entity))
return false;
}
return true;
}
}
/// <summary>Ref struct iterator for queries with four component types.</summary>
public ref struct Select4<T1, T2, T3, T4>
where T1 : struct where T2 : struct where T3 : struct where T4 : struct
{
private readonly World _world;
private readonly SparseSet<T1>? _set1;
private readonly SparseSet<T2>? _set2;
private readonly SparseSet<T3>? _set3;
private readonly SparseSet<T4>? _set4;
private readonly ComponentStore _store;
private readonly HashSet<Type>? _without;
private int _index;
private readonly int _count;
private readonly int _driver;
internal Select4(World world, Query<T1, T2, T3, T4> query)
{
_world = world;
_store = world.Components;
_without = query.WithoutTypes;
_set1 = _store.GetSet(typeof(T1)) as SparseSet<T1>;
_set2 = _store.GetSet(typeof(T2)) as SparseSet<T2>;
_set3 = _store.GetSet(typeof(T3)) as SparseSet<T3>;
_set4 = _store.GetSet(typeof(T4)) as SparseSet<T4>;
int c1 = _set1?.Count ?? 0, c2 = _set2?.Count ?? 0;
int c3 = _set3?.Count ?? 0, c4 = _set4?.Count ?? 0;
int min = c1;
_driver = 0;
if (c2 < min) { min = c2; _driver = 1; }
if (c3 < min) { min = c3; _driver = 2; }
if (c4 < min) { min = c4; _driver = 3; }
_count = min;
_index = -1;
_world.BeginBatching();
}
public Entity Entity { get; private set; }
public Select4<T1, T2, T3, T4> Current => this;
public ref T1 Ref1 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T1)); return ref _set1!.Get(Entity); } }
public ref T2 Ref2 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T2)); return ref _set2!.Get(Entity); } }
public ref T3 Ref3 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T3)); return ref _set3!.Get(Entity); } }
public ref T4 Ref4 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T4)); return ref _set4!.Get(Entity); } }
public ref readonly T1 Val1 => ref _set1!.Get(Entity);
public ref readonly T2 Val2 => ref _set2!.Get(Entity);
public ref readonly T3 Val3 => ref _set3!.Get(Entity);
public ref readonly T4 Val4 => ref _set4!.Get(Entity);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool MoveNext()
{
if (_set1 == null || _set2 == null || _set3 == null || _set4 == null) return false;
while (++_index < _count)
{
switch (_driver)
{
case 0:
Entity = _set1.DenseEntities[_index];
if (!_set2.Contains(Entity)) continue;
if (!_set3.Contains(Entity)) continue;
if (!_set4.Contains(Entity)) continue;
break;
case 1:
Entity = _set2!.DenseEntities[_index];
if (!_set1.Contains(Entity)) continue;
if (!_set3.Contains(Entity)) continue;
if (!_set4.Contains(Entity)) continue;
break;
case 2:
Entity = _set3!.DenseEntities[_index];
if (!_set1.Contains(Entity)) continue;
if (!_set2.Contains(Entity)) continue;
if (!_set4.Contains(Entity)) continue;
break;
case 3:
Entity = _set4!.DenseEntities[_index];
if (!_set1.Contains(Entity)) continue;
if (!_set2.Contains(Entity)) continue;
if (!_set3.Contains(Entity)) continue;
break;
}
if (!PassesWithout()) continue;
if (_world.IsSingletonEntity(Entity)) continue;
return true;
}
return false;
}
public Select4<T1, T2, T3, T4> GetEnumerator() => this;
public void Dispose() => _world.EndBatching();
private bool PassesWithout()
{
if (_without == null) return true;
foreach (var type in _without)
{
var set = _store.GetSet(type);
if (set != null && set.Contains(Entity))
return false;
}
return true;
}
}
/// <summary>Ref struct iterator for queries with five component types.</summary>
public ref struct Select5<T1, T2, T3, T4, T5>
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct
{
private readonly World _world;
private readonly SparseSet<T1>? _set1;
private readonly SparseSet<T2>? _set2;
private readonly SparseSet<T3>? _set3;
private readonly SparseSet<T4>? _set4;
private readonly SparseSet<T5>? _set5;
private readonly ComponentStore _store;
private readonly HashSet<Type>? _without;
private int _index;
private readonly int _count;
private readonly int _driver;
internal Select5(World world, Query<T1, T2, T3, T4, T5> query)
{
_world = world;
_store = world.Components;
_without = query.WithoutTypes;
_set1 = _store.GetSet(typeof(T1)) as SparseSet<T1>;
_set2 = _store.GetSet(typeof(T2)) as SparseSet<T2>;
_set3 = _store.GetSet(typeof(T3)) as SparseSet<T3>;
_set4 = _store.GetSet(typeof(T4)) as SparseSet<T4>;
_set5 = _store.GetSet(typeof(T5)) as SparseSet<T5>;
int c1 = _set1?.Count ?? 0, c2 = _set2?.Count ?? 0;
int c3 = _set3?.Count ?? 0, c4 = _set4?.Count ?? 0;
int c5 = _set5?.Count ?? 0;
int min = c1;
_driver = 0;
if (c2 < min) { min = c2; _driver = 1; }
if (c3 < min) { min = c3; _driver = 2; }
if (c4 < min) { min = c4; _driver = 3; }
if (c5 < min) { min = c5; _driver = 4; }
_count = min;
_index = -1;
_world.BeginBatching();
}
public Entity Entity { get; private set; }
public Select5<T1, T2, T3, T4, T5> Current => this;
public ref T1 Ref1 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T1)); return ref _set1!.Get(Entity); } }
public ref T2 Ref2 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T2)); return ref _set2!.Get(Entity); } }
public ref T3 Ref3 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T3)); return ref _set3!.Get(Entity); } }
public ref T4 Ref4 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T4)); return ref _set4!.Get(Entity); } }
public ref T5 Ref5 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T5)); return ref _set5!.Get(Entity); } }
public ref readonly T1 Val1 => ref _set1!.Get(Entity);
public ref readonly T2 Val2 => ref _set2!.Get(Entity);
public ref readonly T3 Val3 => ref _set3!.Get(Entity);
public ref readonly T4 Val4 => ref _set4!.Get(Entity);
public ref readonly T5 Val5 => ref _set5!.Get(Entity);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool MoveNext()
{
if (_set1 == null || _set2 == null || _set3 == null || _set4 == null || _set5 == null) return false;
while (++_index < _count)
{
switch (_driver)
{
case 0:
Entity = _set1.DenseEntities[_index];
if (!_set2.Contains(Entity)) continue;
if (!_set3.Contains(Entity)) continue;
if (!_set4.Contains(Entity)) continue;
if (!_set5.Contains(Entity)) continue;
break;
case 1:
Entity = _set2!.DenseEntities[_index];
if (!_set1.Contains(Entity)) continue;
if (!_set3.Contains(Entity)) continue;
if (!_set4.Contains(Entity)) continue;
if (!_set5.Contains(Entity)) continue;
break;
case 2:
Entity = _set3!.DenseEntities[_index];
if (!_set1.Contains(Entity)) continue;
if (!_set2.Contains(Entity)) continue;
if (!_set4.Contains(Entity)) continue;
if (!_set5.Contains(Entity)) continue;
break;
case 3:
Entity = _set4!.DenseEntities[_index];
if (!_set1.Contains(Entity)) continue;
if (!_set2.Contains(Entity)) continue;
if (!_set3.Contains(Entity)) continue;
if (!_set5.Contains(Entity)) continue;
break;
case 4:
Entity = _set5!.DenseEntities[_index];
if (!_set1.Contains(Entity)) continue;
if (!_set2.Contains(Entity)) continue;
if (!_set3.Contains(Entity)) continue;
if (!_set4.Contains(Entity)) continue;
break;
}
if (!PassesWithout()) continue;
if (_world.IsSingletonEntity(Entity)) continue;
return true;
}
return false;
}
public Select5<T1, T2, T3, T4, T5> GetEnumerator() => this;
public void Dispose() => _world.EndBatching();
private bool PassesWithout()
{
if (_without == null) return true;
foreach (var type in _without)
{
var set = _store.GetSet(type);
if (set != null && set.Contains(Entity))
return false;
}
return true;
}
}
/// <summary>Ref struct iterator for queries with six component types.</summary>
public ref struct Select6<T1, T2, T3, T4, T5, T6>
where T1 : struct where T2 : struct where T3 : struct
where T4 : struct where T5 : struct where T6 : struct
{
private readonly World _world;
private readonly SparseSet<T1>? _set1;
private readonly SparseSet<T2>? _set2;
private readonly SparseSet<T3>? _set3;
private readonly SparseSet<T4>? _set4;
private readonly SparseSet<T5>? _set5;
private readonly SparseSet<T6>? _set6;
private readonly ComponentStore _store;
private readonly HashSet<Type>? _without;
private int _index;
private readonly int _count;
private readonly int _driver;
internal Select6(World world, Query<T1, T2, T3, T4, T5, T6> query)
{
_world = world;
_store = world.Components;
_without = query.WithoutTypes;
_set1 = _store.GetSet(typeof(T1)) as SparseSet<T1>;
_set2 = _store.GetSet(typeof(T2)) as SparseSet<T2>;
_set3 = _store.GetSet(typeof(T3)) as SparseSet<T3>;
_set4 = _store.GetSet(typeof(T4)) as SparseSet<T4>;
_set5 = _store.GetSet(typeof(T5)) as SparseSet<T5>;
_set6 = _store.GetSet(typeof(T6)) as SparseSet<T6>;
int c1 = _set1?.Count ?? 0, c2 = _set2?.Count ?? 0;
int c3 = _set3?.Count ?? 0, c4 = _set4?.Count ?? 0;
int c5 = _set5?.Count ?? 0, c6 = _set6?.Count ?? 0;
int min = c1;
_driver = 0;
if (c2 < min) { min = c2; _driver = 1; }
if (c3 < min) { min = c3; _driver = 2; }
if (c4 < min) { min = c4; _driver = 3; }
if (c5 < min) { min = c5; _driver = 4; }
if (c6 < min) { min = c6; _driver = 5; }
_count = min;
_index = -1;
_world.BeginBatching();
}
public Entity Entity { get; private set; }
public Select6<T1, T2, T3, T4, T5, T6> Current => this;
public ref T1 Ref1 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T1)); return ref _set1!.Get(Entity); } }
public ref T2 Ref2 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T2)); return ref _set2!.Get(Entity); } }
public ref T3 Ref3 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T3)); return ref _set3!.Get(Entity); } }
public ref T4 Ref4 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T4)); return ref _set4!.Get(Entity); } }
public ref T5 Ref5 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T5)); return ref _set5!.Get(Entity); } }
public ref T6 Ref6 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T6)); return ref _set6!.Get(Entity); } }
public ref readonly T1 Val1 => ref _set1!.Get(Entity);
public ref readonly T2 Val2 => ref _set2!.Get(Entity);
public ref readonly T3 Val3 => ref _set3!.Get(Entity);
public ref readonly T4 Val4 => ref _set4!.Get(Entity);
public ref readonly T5 Val5 => ref _set5!.Get(Entity);
public ref readonly T6 Val6 => ref _set6!.Get(Entity);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool MoveNext()
{
if (_set1 == null || _set2 == null || _set3 == null || _set4 == null || _set5 == null || _set6 == null) return false;
while (++_index < _count)
{
switch (_driver)
{
case 0:
Entity = _set1.DenseEntities[_index];
if (!_set2.Contains(Entity)) continue;
if (!_set3.Contains(Entity)) continue;
if (!_set4.Contains(Entity)) continue;
if (!_set5.Contains(Entity)) continue;
if (!_set6.Contains(Entity)) continue;
break;
case 1:
Entity = _set2!.DenseEntities[_index];
if (!_set1.Contains(Entity)) continue;
if (!_set3.Contains(Entity)) continue;
if (!_set4.Contains(Entity)) continue;
if (!_set5.Contains(Entity)) continue;
if (!_set6.Contains(Entity)) continue;
break;
case 2:
Entity = _set3!.DenseEntities[_index];
if (!_set1.Contains(Entity)) continue;
if (!_set2.Contains(Entity)) continue;
if (!_set4.Contains(Entity)) continue;
if (!_set5.Contains(Entity)) continue;
if (!_set6.Contains(Entity)) continue;
break;
case 3:
Entity = _set4!.DenseEntities[_index];
if (!_set1.Contains(Entity)) continue;
if (!_set2.Contains(Entity)) continue;
if (!_set3.Contains(Entity)) continue;
if (!_set5.Contains(Entity)) continue;
if (!_set6.Contains(Entity)) continue;
break;
case 4:
Entity = _set5!.DenseEntities[_index];
if (!_set1.Contains(Entity)) continue;
if (!_set2.Contains(Entity)) continue;
if (!_set3.Contains(Entity)) continue;
if (!_set4.Contains(Entity)) continue;
if (!_set6.Contains(Entity)) continue;
break;
case 5:
Entity = _set6!.DenseEntities[_index];
if (!_set1.Contains(Entity)) continue;
if (!_set2.Contains(Entity)) continue;
if (!_set3.Contains(Entity)) continue;
if (!_set4.Contains(Entity)) continue;
if (!_set5.Contains(Entity)) continue;
break;
}
if (!PassesWithout()) continue;
if (_world.IsSingletonEntity(Entity)) continue;
return true;
}
return false;
}
public Select6<T1, T2, T3, T4, T5, T6> GetEnumerator() => this;
public void Dispose() => _world.EndBatching();
private bool PassesWithout()
{
if (_without == null) return true;
foreach (var type in _without)
{
var set = _store.GetSet(type);
if (set != null && set.Contains(Entity))
return false;
}
return true;
}
}

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