Compare commits
27
Commits
3b08138580
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d73b7ebf37 | ||
|
|
a6287911aa | ||
|
|
53fa3b742d | ||
|
|
8a32588c54 | ||
|
|
6207872e45 | ||
|
|
1bb290e60c | ||
|
|
2a3feb556f | ||
|
|
68eeeeb7a6 | ||
|
|
b0bf10f286 | ||
|
|
535c8d948e | ||
|
|
94a9cc9519 | ||
|
|
9ef387f010 | ||
|
|
4871f68fb8 | ||
|
|
6e8ac0adc0 | ||
|
|
cbb7edd472 | ||
|
|
4cdfe9c957 | ||
|
|
52e1e8fdc9 | ||
|
|
4b94f411bf | ||
|
|
1e8e4e1b38 | ||
|
|
5d7eb14911 | ||
|
|
d9cd943c52 | ||
|
|
e460c0e70f | ||
|
|
96d732d6ab | ||
|
|
b98e8d66af | ||
|
|
737136e2ef | ||
|
|
91c6f4aa2c | ||
|
|
2de735498c |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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** (1–6 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>()` (1–3 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
|
||||
@@ -217,3 +248,55 @@ never auto-marks.
|
||||
`WorldSerializer.Save/Load` uses the source-generated `ComponentRegistry`.
|
||||
All component types used with `World` generic methods are automatically
|
||||
discovered. Serialization round-trips must be tested — see `testing-games` skill.
|
||||
|
||||
## Interrupts
|
||||
|
||||
Interrupts pause system execution until an external response arrives. A system
|
||||
issues an interrupt, the next tick is skipped, and a handler command resolves it.
|
||||
|
||||
### Issuing an Interrupt
|
||||
|
||||
```csharp
|
||||
[MessagePackObject]
|
||||
public record struct ConfirmInterrupt : IInterrupt
|
||||
{
|
||||
[Key(0)] public string Message;
|
||||
}
|
||||
|
||||
// In a system:
|
||||
world.Interrupt(new ConfirmInterrupt { Message = "Are you sure?" });
|
||||
```
|
||||
|
||||
The current tick completes normally. The **next** tick is blocked — `SystemGroup`
|
||||
skips all systems but still drains commands and posts changes.
|
||||
|
||||
### Handling an Interrupt
|
||||
|
||||
Handler commands implement `IInterruptHandlerCommand<T>`. The default `Execute`
|
||||
calls `TryResolve` with the pending interrupt. Return `true` to resolve it,
|
||||
`false` to leave it pending for another handler:
|
||||
|
||||
```csharp
|
||||
[MessagePackObject]
|
||||
public struct ConfirmHandler : IInterruptHandlerCommand<ConfirmInterrupt>
|
||||
{
|
||||
[Key(0)] public bool Confirmed;
|
||||
|
||||
public bool TryResolve(ConfirmInterrupt interrupt)
|
||||
{
|
||||
// Do work here. The interrupt is just a signal.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// External code (e.g., UI button) enqueues the handler:
|
||||
world.Commands.Enqueue(new ConfirmHandler { Confirmed = true });
|
||||
```
|
||||
|
||||
### Key Rules
|
||||
|
||||
- Only one interrupt of a given type may be pending at a time. A second call
|
||||
to `Interrupt<T>` with the same type throws.
|
||||
- Interrupts are transient — they are not serialized and do not survive save/load.
|
||||
- If no `SystemGroup` manages the world, `Interrupt<T>()` is a no-op.
|
||||
- Check pending interrupts with `world.HasInterrupt<T>()`.
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Game.Blackjack\Blackjack.csproj" />
|
||||
<ProjectReference Include="..\OECS.PlayTest\OECS.PlayTest.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -17,38 +17,34 @@ public struct NewRoundCommand : ICommand
|
||||
return;
|
||||
|
||||
// Clear hands from previous round.
|
||||
var singletonEntity = World.SingletonEntity;
|
||||
var handEntities = new List<Entity>();
|
||||
using (var iter = world.Select<PlayerHand>())
|
||||
{
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != singletonEntity)
|
||||
handEntities.Add(iter.CurrentEntity);
|
||||
handEntities.Add(iter.Entity);
|
||||
}
|
||||
}
|
||||
using (var iter = world.Select<DealerHand>())
|
||||
{
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != singletonEntity)
|
||||
handEntities.Add(iter.CurrentEntity);
|
||||
handEntities.Add(iter.Entity);
|
||||
}
|
||||
}
|
||||
foreach (var hand in handEntities)
|
||||
{
|
||||
var cards = world.GetSources<Holds>(hand);
|
||||
var deckEntity = HitCommand.FindEntity<Deck>(world, singletonEntity);
|
||||
var deckEntity = world.FindEntity<Deck>();
|
||||
foreach (var card in cards)
|
||||
{
|
||||
world.RemoveComponent<Holds>(card);
|
||||
world.AddComponent(card, new InDeck { Source = card, Target = deckEntity });
|
||||
world.AddComponent(card, new InDeck { Target = deckEntity });
|
||||
}
|
||||
}
|
||||
|
||||
state.RoundNumber++;
|
||||
state.Phase = GamePhase.Betting;
|
||||
state.Result = RoundResult.None;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,5 @@ public struct PlaceBetCommand : ICommand
|
||||
state.CurrentBet = Amount;
|
||||
state.Chips -= Amount;
|
||||
state.Phase = GamePhase.Dealing;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,5 @@ public struct StandCommand : ICommand
|
||||
return;
|
||||
|
||||
state.Phase = GamePhase.DealerTurn;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -22,18 +22,7 @@ public static class HandUtil
|
||||
where T : struct
|
||||
{
|
||||
// Find the hand entity.
|
||||
Entity handEntity = Entity.Null;
|
||||
using (var iter = world.Select<T>())
|
||||
{
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != World.SingletonEntity)
|
||||
{
|
||||
handEntity = iter.CurrentEntity;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
var handEntity = world.FindEntity<T>();
|
||||
|
||||
if (handEntity == Entity.Null)
|
||||
return 0;
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace Game.Blackjack;
|
||||
/// </summary>
|
||||
public class PlayerBustCheckSystem : ISystem
|
||||
{
|
||||
public void Run(World world)
|
||||
public void RunImpl(World world)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.PlayerTurn)
|
||||
@@ -21,6 +21,5 @@ public class PlayerBustCheckSystem : ISystem
|
||||
ref var mutableState = ref world.GetSingleton<GameState>();
|
||||
mutableState.Phase = GamePhase.RoundOver;
|
||||
mutableState.Result = RoundResult.PlayerBust;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using FluentAssertions;
|
||||
using Game.CardWars;
|
||||
using OECS;
|
||||
using Xunit;
|
||||
|
||||
namespace Game.CardWars.Tests;
|
||||
|
||||
public class CardEffectTests
|
||||
{
|
||||
private static (World World, SystemGroup Group) Setup()
|
||||
{
|
||||
CardEffectRegistry.ClearForTests();
|
||||
return GameFactory.Create(playerCount: 2, seed: 42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarriorEffect_AddsHornWhenAnotherWarriorExists()
|
||||
{
|
||||
var (world, group) = Setup();
|
||||
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
|
||||
|
||||
// Play two warriors.
|
||||
var hand = GameUtil.GetHandCards(world, player);
|
||||
var warrior1 = FindCardWithEffect(world, hand, CardEffect.Warrior);
|
||||
var warrior2 = FindSecondCardWithEffect(world, hand, CardEffect.Warrior);
|
||||
|
||||
if (warrior1 == Entity.Null || warrior2 == Entity.Null) return; // Not enough warriors in deck.
|
||||
|
||||
var def = world.ReadComponent<CardDef>(warrior1);
|
||||
|
||||
world.Commands.Enqueue(new PlayCardCommand
|
||||
{
|
||||
CardEntity = warrior1,
|
||||
Rank = def.Ranks[0],
|
||||
FaceDown = false,
|
||||
TargetPlayer = null
|
||||
});
|
||||
group.RunLogical();
|
||||
|
||||
// First warrior: no other warrior, no horn.
|
||||
GameUtil.GetFieldCards(world, player).Should().Contain(warrior1);
|
||||
world.HasComponent<Horn>(warrior1).Should().BeFalse(because: "no other warrior on field");
|
||||
|
||||
// Play second warrior.
|
||||
var def2 = world.ReadComponent<CardDef>(warrior2);
|
||||
world.Commands.Enqueue(new PlayCardCommand
|
||||
{
|
||||
CardEntity = warrior2,
|
||||
Rank = def2.Ranks[0],
|
||||
FaceDown = false,
|
||||
TargetPlayer = null
|
||||
});
|
||||
group.RunLogical();
|
||||
|
||||
// Second warrior has another warrior, should get horn.
|
||||
// But Wait — the effect checks "other" warriors on field. The first warrior
|
||||
// is on field, so the second warrior should get a horn.
|
||||
world.HasComponent<Horn>(warrior2).Should().BeTrue(because: "another warrior exists on field");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MercenaryEffect_SetsPendingChoice()
|
||||
{
|
||||
var (world, group) = Setup();
|
||||
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
|
||||
|
||||
// Play first mercenary (no effect).
|
||||
var hand = GameUtil.GetHandCards(world, player);
|
||||
var merc = FindCardWithEffect(world, hand, CardEffect.Mercenary);
|
||||
if (merc == Entity.Null) return;
|
||||
|
||||
var def = world.ReadComponent<CardDef>(merc);
|
||||
world.Commands.Enqueue(new PlayCardCommand
|
||||
{
|
||||
CardEntity = merc,
|
||||
Rank = def.Ranks[0],
|
||||
FaceDown = false,
|
||||
TargetPlayer = null
|
||||
});
|
||||
group.RunLogical();
|
||||
|
||||
// Play second mercenary. We need another one from a drawn card.
|
||||
var publicDeck = GameUtil.FindEntity<PublicDeck>(world);
|
||||
GameUtil.DrawCard(world, publicDeck, player);
|
||||
var newHand = GameUtil.GetHandCards(world, player);
|
||||
var merc2 = FindCardWithEffect(world, newHand, CardEffect.Mercenary);
|
||||
if (merc2 == Entity.Null) return;
|
||||
|
||||
var def2 = world.ReadComponent<CardDef>(merc2);
|
||||
world.Commands.Enqueue(new PlayCardCommand
|
||||
{
|
||||
CardEntity = merc2,
|
||||
Rank = def2.Ranks[0],
|
||||
FaceDown = false,
|
||||
TargetPlayer = null
|
||||
});
|
||||
group.RunLogical();
|
||||
|
||||
// Should have a pending Mercenary choice.
|
||||
world.HasSingleton<PendingMercenary>().Should().BeTrue();
|
||||
var pending = world.ReadSingleton<PendingMercenary>();
|
||||
pending.CardEntity.Should().Be(merc2);
|
||||
|
||||
// Resolve the choice (pick a target).
|
||||
world.Commands.Enqueue(new ResolveChoiceCommand { Target = merc });
|
||||
group.RunLogical();
|
||||
|
||||
// The pending should be cleared.
|
||||
world.HasSingleton<PendingMercenary>().Should().BeFalse();
|
||||
}
|
||||
|
||||
private static Entity FindCardWithEffect(World world, List<Entity> cards, CardEffect effect)
|
||||
{
|
||||
foreach (var c in cards)
|
||||
{
|
||||
if (world.HasComponent<CardDef>(c) && world.ReadComponent<CardDef>(c).Effect == effect)
|
||||
return c;
|
||||
}
|
||||
return Entity.Null;
|
||||
}
|
||||
|
||||
private static Entity FindSecondCardWithEffect(World world, List<Entity> cards, CardEffect effect)
|
||||
{
|
||||
Entity first = Entity.Null;
|
||||
foreach (var c in cards)
|
||||
{
|
||||
if (world.HasComponent<CardDef>(c) && world.ReadComponent<CardDef>(c).Effect == effect)
|
||||
{
|
||||
if (first == Entity.Null) first = c;
|
||||
else return c;
|
||||
}
|
||||
}
|
||||
return Entity.Null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>Game.CardWars.Tests</RootNamespace>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Game.CardWars\CardWars.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component for a player's banner entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct Banner { }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component for a faction deck entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct FactionDeck { }
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Per-effect pending choice components.
|
||||
// Each is placed on the singleton entity when a card effect
|
||||
// requires player input. Their presence blocks game advancement.
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Mercenary: choose a combatant to give a skull.</summary>
|
||||
[MessagePackObject]
|
||||
public record struct PendingMercenary
|
||||
{
|
||||
[Key(0)] public Entity CardEntity;
|
||||
[Key(1)] public Entity Player;
|
||||
}
|
||||
|
||||
/// <summary>Dancer: choose up to 2 combatants for horn. Step tracks progress.</summary>
|
||||
[MessagePackObject]
|
||||
public record struct PendingDancer
|
||||
{
|
||||
[Key(0)] public Entity CardEntity;
|
||||
[Key(1)] public Entity Player;
|
||||
[Key(2)] public int Step; // 0 = first choice, 1 = second choice
|
||||
}
|
||||
|
||||
/// <summary>Paladin: choose a combatant or banner for horn.</summary>
|
||||
[MessagePackObject]
|
||||
public record struct PendingPaladin
|
||||
{
|
||||
[Key(0)] public Entity CardEntity;
|
||||
[Key(1)] public Entity Player;
|
||||
}
|
||||
|
||||
/// <summary>Nun: choose a field card to discard.</summary>
|
||||
[MessagePackObject]
|
||||
public record struct PendingNun
|
||||
{
|
||||
[Key(0)] public Entity CardEntity;
|
||||
[Key(1)] public Entity Player;
|
||||
}
|
||||
|
||||
/// <summary>Scout: choose an opponent player to scout.</summary>
|
||||
[MessagePackObject]
|
||||
public record struct PendingScout
|
||||
{
|
||||
[Key(0)] public Entity CardEntity;
|
||||
[Key(1)] public Entity Player;
|
||||
}
|
||||
|
||||
/// <summary>Pegasus: choose a field card to return to hand (or null to pass).</summary>
|
||||
[MessagePackObject]
|
||||
public record struct PendingPegasus
|
||||
{
|
||||
[Key(0)] public Entity CardEntity;
|
||||
[Key(1)] public Entity Player;
|
||||
}
|
||||
|
||||
/// <summary>CurseMaster: choose a horn token to turn into skull.</summary>
|
||||
[MessagePackObject]
|
||||
public record struct PendingCurseMaster
|
||||
{
|
||||
[Key(0)] public Entity CardEntity;
|
||||
[Key(1)] public Entity Player;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Helper to detect any pending component on the singleton.
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
public static class PendingChoice
|
||||
{
|
||||
/// <summary>Returns true if any pending choice component exists on the singleton.</summary>
|
||||
public static bool Any(World world)
|
||||
{
|
||||
return world.HasSingleton<PendingMercenary>()
|
||||
|| world.HasSingleton<PendingDancer>()
|
||||
|| world.HasSingleton<PendingPaladin>()
|
||||
|| world.HasSingleton<PendingNun>()
|
||||
|| world.HasSingleton<PendingScout>()
|
||||
|| world.HasSingleton<PendingPegasus>()
|
||||
|| world.HasSingleton<PendingCurseMaster>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Placed on the singleton when a 决斗家 (Duelist) is played.
|
||||
/// The play phase ends when the turn comes back to this player.
|
||||
/// Removed automatically when the phase transitions.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct PendingDuelist
|
||||
{
|
||||
[Key(0)] public int PlayerIndex;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component for the public deck entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct PublicDeck { }
|
||||
@@ -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 { }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
# 魔烬纷争
|
||||
|
||||
## 游戏概述
|
||||
|
||||
魔烬纷争是一款快节奏多人桌面卡牌战术游戏。
|
||||
|
||||
玩家扮演一个日式异世界中的领袖,需要带领自己的势力击败其他玩家,建立王国。
|
||||
|
||||
支持人数2-5人,每局游戏约10分钟/人。
|
||||
|
||||
## 游戏配件
|
||||
|
||||
- 公共牌:60张
|
||||
- 旗帜牌:6张
|
||||
|
||||
- 标记若干
|
||||
- 骷髅标记:15个
|
||||
- 号角标记:15个
|
||||
|
||||
- 城堡标记:33个
|
||||
- 黑、白、蓝、棕、黄、靛色城堡各5座,正面标记 2 个皇冠。
|
||||
- 金城堡3座,正面标记 3 个皇冠。
|
||||
- 背面朝上的城堡当做单个皇冠标记使用。
|
||||
|
||||
## 游戏布置
|
||||
|
||||
公共布置:
|
||||
- 将公共牌洗混,放在桌面中央,形成抽牌堆。
|
||||
- 将城堡标记洗混,随机抽取1张正面朝上与1张背面朝上,放在桌面中央。
|
||||
-【5+人游戏】:额外抽取1张正面朝上放在桌面中央。
|
||||
|
||||
每名玩家布置:
|
||||
- 选择一张旗帜牌,放在自己面前靠左的位置。
|
||||
- 抓5张公共牌作为起始手牌。
|
||||
- 获得1个王冠标记。
|
||||
|
||||
以阅读规则的玩家为起始玩家开始游戏。
|
||||
|
||||
## 游戏目标
|
||||
|
||||
有玩家获得7个王冠时,游戏结束。分数最高的玩家赢得游戏胜利。
|
||||
|
||||
## 游戏流程
|
||||
|
||||
游戏按轮进行,每轮进行以下流程:
|
||||
|
||||
- **出牌阶段**:玩家轮流选择卡牌打出或盖放。
|
||||
- **翻牌阶段**:玩家轮流选择翻开一张自己的盖放牌。
|
||||
- **结算阶段**:检查所有玩家的得分情况,决定胜者。
|
||||
- **清理阶段**:清理战场,准备下一轮游戏。
|
||||
|
||||
### 出牌阶段
|
||||
|
||||
从起始玩家开始,每名玩家轮流进行出牌。
|
||||
|
||||
出牌时,可从手牌打出一张牌,或者选择跳过。
|
||||
|
||||
若玩家选择跳过,则会结束该玩家的本次出牌阶段。
|
||||
|
||||
出牌时,按照卡牌描述的方式打出牌:
|
||||
|
||||
- 将打出的卡牌放到目标玩家面前,放在其他打出卡牌的右侧。
|
||||
- 卡牌默认打出给自己,除非特殊说明。
|
||||
- 如果卡牌标记了盖放,则这张牌必须盖放打出,否则必须正面打出。
|
||||
|
||||
### 翻牌阶段
|
||||
|
||||
从起始玩家开始,每名玩家轮流进行翻牌。
|
||||
|
||||
在玩家面前有盖放牌时,玩家必须选择其中一张翻开。
|
||||
|
||||
否则,结束该玩家的本次翻牌阶段。
|
||||
|
||||
### 结算阶段
|
||||
|
||||
将牌面点数和领袖牌点数全部加总,作为玩家本轮的总战力。
|
||||
|
||||
按总战力从高到低的顺序,从桌面中央获得正面或背面朝上的城堡标记,直到拿空。
|
||||
若有玩家打平,则行动顺序更靠前的玩家赢得胜利。
|
||||
|
||||
每当有玩家赢得正面朝上的城堡标记,若其他玩家拥有同色城堡,则他们必须为其每个同色城堡选择:
|
||||
- 支付1个王冠标记给胜者;
|
||||
- 从胜者处获得1个王冠标记;将城堡交给胜者。
|
||||
|
||||
由本轮胜者决定其他玩家做选择的顺序。
|
||||
|
||||
最后,按设置的方式补充中央的城堡标记,然后开始下一轮游戏。
|
||||
|
||||
### 清理阶段
|
||||
|
||||
将本回合打出的所有公共牌放到弃牌堆里。
|
||||
|
||||
如果旗帜或其他牌上有骷髅或号角标记,将这些标记放回供应堆。
|
||||
|
||||
然后,每名玩家抓3张公共牌开始下一轮。
|
||||
|
||||
## 抓牌
|
||||
|
||||
当玩家抓牌时,如果抓牌堆为空,则将对应的弃牌堆洗回牌堆再继续。
|
||||
|
||||
## 号角与骷髅
|
||||
|
||||
- 号角可以放在公共牌上,也可以放在旗帜上。
|
||||
- 放在旗帜上时,为自己每张公共牌提供+1战力。
|
||||
- 放在公共牌上时,为这张牌增加1倍战力。
|
||||
- 骷髅与号角类似,但是扣减1倍战力而非增加。
|
||||
- 骷髅与号角相互抵消,同类效果可叠加。战力可能会变为负数。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 魔烬纷争 - 势力扩展
|
||||
|
||||
## 游戏概述
|
||||
|
||||
势力扩展为魔烬纷争增加了不同的专属势力卡牌。
|
||||
|
||||
## 游戏配件
|
||||
|
||||
势力配件:10 组
|
||||
- 领袖牌:每个势力1张
|
||||
- 势力牌:每个势力15张
|
||||
- 金币标记(商人势力):6个
|
||||
|
||||
## 游戏布置
|
||||
|
||||
对玩家布置进行以下调整:
|
||||
- 选择一套势力配件获得。
|
||||
- 将势力牌洗混放在旗帜牌下方。左侧留出空间作为势力牌弃牌堆。
|
||||
- 将领袖牌放在旗帜牌左侧。
|
||||
- 额外抓2张势力牌,然后选择两张牌弃掉。
|
||||
|
||||
领袖牌规则:
|
||||
- 效果总是生效,战力计入总点数。可以获得号角或骷髅。
|
||||
- 不计入旗帜的卡牌数目,战斗结束时只清理号角/骷髅标记不清理牌。
|
||||
|
||||
势力牌规则:
|
||||
- 每轮清理阶段抓牌时,改为抓 2 张公共牌和 1 张势力牌。
|
||||
- 因卡牌效果抓牌时,可以选择抓公共牌或者势力牌。
|
||||
- 势力牌弃掉时,放入独立的势力牌弃牌堆。势力牌堆为空而选择抓势力牌时,将其洗回势力牌堆。
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Game.TicTacToe\TicTacToe.csproj" />
|
||||
<ProjectReference Include="..\OECS.PlayTest\OECS.PlayTest.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
protected override List<PlaceMarkCommand> GetLegalActions(World world)
|
||||
{
|
||||
private static readonly Random _rng = new();
|
||||
public PlaceMarkCommand Decide(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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -44,11 +44,17 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
||||
"GetSources",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> _forEachNames = new()
|
||||
private static readonly HashSet<string> _singletonMethods = new()
|
||||
{
|
||||
"ForEach",
|
||||
"SetSingleton",
|
||||
"GetSingleton",
|
||||
"ReadSingleton",
|
||||
"HasSingleton",
|
||||
"RemoveSingleton",
|
||||
};
|
||||
|
||||
|
||||
|
||||
public void Initialize(IncrementalGeneratorInitializationContext context)
|
||||
{
|
||||
// Collect all generic method invocations that reference component types.
|
||||
@@ -57,17 +63,9 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
||||
predicate: IsCandidateInvocation,
|
||||
transform: ExtractComponentType)
|
||||
.Where(t => t.FullyQualifiedName != null)
|
||||
.Select((t, _) => (t.FullyQualifiedName!, t.AssemblyQualifiedName!, t.IsRelationship));
|
||||
.Select((t, _) => (t.FullyQualifiedName!, t.AssemblyQualifiedName!, t.IsRelationship, t.IsSingleton));
|
||||
|
||||
// Also collect ForEach type arguments from the World class.
|
||||
var forEachCalls = context.SyntaxProvider
|
||||
.CreateSyntaxProvider(
|
||||
predicate: IsForEachCandidate,
|
||||
transform: ExtractForEachTypes)
|
||||
.Where(list => list.Length > 0)
|
||||
.SelectMany((list, _) => list);
|
||||
|
||||
var allTypes = invocations.Collect().Combine(forEachCalls.Collect());
|
||||
var allTypes = invocations.Collect();
|
||||
|
||||
context.RegisterSourceOutput(allTypes, GenerateRegistry);
|
||||
}
|
||||
@@ -88,23 +86,9 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsForEachCandidate(SyntaxNode node, CancellationToken _)
|
||||
{
|
||||
if (node is not InvocationExpressionSyntax invocation)
|
||||
return false;
|
||||
|
||||
if (invocation.Expression is MemberAccessExpressionSyntax memberAccess)
|
||||
{
|
||||
var name = memberAccess.Name is GenericNameSyntax gn
|
||||
? gn.Identifier.Text
|
||||
: memberAccess.Name.Identifier.Text;
|
||||
return _forEachNames.Contains(name);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static (string? FullyQualifiedName, string? AssemblyQualifiedName, bool IsRelationship) ExtractComponentType(
|
||||
private static (string? FullyQualifiedName, string? AssemblyQualifiedName, bool IsRelationship, bool IsSingleton) ExtractComponentType(
|
||||
GeneratorSyntaxContext ctx, CancellationToken _)
|
||||
{
|
||||
if (ctx.Node is not InvocationExpressionSyntax invocation)
|
||||
@@ -140,58 +124,25 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
||||
bool isRelationship = typeSymbol.AllInterfaces.Any(i =>
|
||||
i.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::OECS.IRelationship");
|
||||
|
||||
return (fqn, aqn, isRelationship);
|
||||
bool isSingleton = _singletonMethods.Contains(genericName.Identifier.Text);
|
||||
|
||||
return (fqn, aqn, isRelationship, isSingleton);
|
||||
}
|
||||
|
||||
private static ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> ExtractForEachTypes(
|
||||
GeneratorSyntaxContext ctx, CancellationToken _)
|
||||
{
|
||||
if (ctx.Node is not InvocationExpressionSyntax invocation)
|
||||
return ImmutableArray<(string, string, bool)>.Empty;
|
||||
|
||||
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
|
||||
return ImmutableArray<(string, string, bool)>.Empty;
|
||||
|
||||
if (memberAccess.Name is not GenericNameSyntax genericName)
|
||||
return ImmutableArray<(string, string, bool)>.Empty;
|
||||
|
||||
var types = ImmutableArray.CreateBuilder<(string, string, bool)>();
|
||||
foreach (var typeArg in genericName.TypeArgumentList.Arguments)
|
||||
{
|
||||
var symbolInfo = ctx.SemanticModel.GetSymbolInfo(typeArg);
|
||||
if (symbolInfo.Symbol is INamedTypeSymbol typeSymbol &&
|
||||
typeSymbol.IsValueType && !typeSymbol.IsAbstract &&
|
||||
typeSymbol.DeclaredAccessibility == Accessibility.Public)
|
||||
{
|
||||
var fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
|
||||
var aqn = typeSymbol.ContainingAssembly != null
|
||||
? $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted))}, {typeSymbol.ContainingAssembly.Name}"
|
||||
: fqn;
|
||||
bool isRelationship = typeSymbol.AllInterfaces.Any(i =>
|
||||
i.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::OECS.IRelationship");
|
||||
types.Add((fqn, aqn, isRelationship));
|
||||
}
|
||||
}
|
||||
|
||||
return types.ToImmutable();
|
||||
}
|
||||
|
||||
private static void GenerateRegistry(SourceProductionContext context,
|
||||
(ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> Left,
|
||||
ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> Right) data)
|
||||
ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship, bool IsSingleton)> invocations)
|
||||
{
|
||||
var (invocations, forEachTypes) = data;
|
||||
|
||||
// Collect unique types by fully-qualified name, deduplicating.
|
||||
var typeMap = new Dictionary<string, (string Fqn, string Aqn, bool IsRel)>();
|
||||
var typeMap = new Dictionary<string, (string Fqn, string Aqn, bool IsRel, bool IsSingleton)>();
|
||||
foreach (var t in invocations)
|
||||
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship);
|
||||
foreach (var t in forEachTypes)
|
||||
{
|
||||
if (!typeMap.ContainsKey(t.FullyQualifiedName))
|
||||
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship);
|
||||
else if (t.IsRelationship)
|
||||
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, true);
|
||||
if (typeMap.TryGetValue(t.FullyQualifiedName, out var existing))
|
||||
typeMap[t.FullyQualifiedName] = (existing.Fqn, existing.Aqn, existing.IsRel || t.IsRelationship, existing.IsSingleton || t.IsSingleton);
|
||||
else
|
||||
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship, t.IsSingleton);
|
||||
}
|
||||
|
||||
if (typeMap.Count == 0)
|
||||
@@ -213,7 +164,7 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
||||
sb.AppendLine(" {");
|
||||
|
||||
bool first = true;
|
||||
foreach (var (fqn, aqn, isRel) in typeMap.Values.OrderBy(t => t.Fqn))
|
||||
foreach (var (fqn, aqn, isRel, isSingleton) in typeMap.Values.OrderBy(t => t.Fqn))
|
||||
{
|
||||
if (!first)
|
||||
sb.AppendLine(",");
|
||||
@@ -225,7 +176,8 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
||||
sb.AppendLine($" serialize: obj => MessagePackSerializer.Serialize(({fqn})obj),");
|
||||
sb.AppendLine($" deserializeAndAdd: (world, entity, data) =>");
|
||||
sb.AppendLine($" world.AddComponent(entity, MessagePackSerializer.Deserialize<{fqn}>(data)),");
|
||||
sb.AppendLine($" deserialize: data => MessagePackSerializer.Deserialize<{fqn}>(data)");
|
||||
sb.AppendLine($" deserialize: data => MessagePackSerializer.Deserialize<{fqn}>(data),");
|
||||
sb.AppendLine($" isSingleton: {(isSingleton ? "true" : "false")}");
|
||||
|
||||
sb.Append(" )");
|
||||
}
|
||||
|
||||
+14
-26
@@ -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)
|
||||
{
|
||||
_query = world.Query().With<TestPosition>().Build();
|
||||
}
|
||||
|
||||
public void Run(World world)
|
||||
{
|
||||
world.ForEach(_query, (Entity e, ref TestPosition pos) =>
|
||||
public void RunImpl(World world)
|
||||
{
|
||||
foreach (var it in world.Select<TestPosition>())
|
||||
SeenCount++;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
-128
@@ -111,7 +111,6 @@ public class EdgeCaseTests
|
||||
sources.Add(source);
|
||||
world.AddComponent(source, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = source,
|
||||
Target = target
|
||||
});
|
||||
}
|
||||
@@ -154,45 +153,6 @@ public class EdgeCaseTests
|
||||
c.Kind == ChangeKind.EntityRemoved && c.Entity == entity);
|
||||
}
|
||||
|
||||
// ── QueryDescriptor equality ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void QueryDescriptor_EqualQueries_AreEqual()
|
||||
{
|
||||
var q1 = new QueryBuilder().With<Position>().With<Velocity>().Without<Frozen>().Build();
|
||||
var q2 = new QueryBuilder().With<Position>().With<Velocity>().Without<Frozen>().Build();
|
||||
|
||||
q1.Should().Be(q2);
|
||||
q1.GetHashCode().Should().Be(q2.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryDescriptor_DifferentWith_AreNotEqual()
|
||||
{
|
||||
var q1 = new QueryBuilder().With<Position>().Build();
|
||||
var q2 = new QueryBuilder().With<Velocity>().Build();
|
||||
|
||||
q1.Should().NotBe(q2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryDescriptor_DifferentWithout_AreNotEqual()
|
||||
{
|
||||
var q1 = new QueryBuilder().With<Position>().Without<Frozen>().Build();
|
||||
var q2 = new QueryBuilder().With<Position>().Without<Burning>().Build();
|
||||
|
||||
q1.Should().NotBe(q2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryDescriptor_DifferentCount_AreNotEqual()
|
||||
{
|
||||
var q1 = new QueryBuilder().With<Position>().Build();
|
||||
var q2 = new QueryBuilder().With<Position>().With<Velocity>().Build();
|
||||
|
||||
q1.Should().NotBe(q2);
|
||||
}
|
||||
|
||||
// ── Query with 4, 5, 6 components ────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -205,18 +165,14 @@ public class EdgeCaseTests
|
||||
world.AddComponent(entity, new Health { Value = 100 });
|
||||
world.AddComponent(entity, new Frozen());
|
||||
|
||||
var query = world.Query()
|
||||
.With<Position>().With<Velocity>().With<Health>().With<Frozen>()
|
||||
.Build();
|
||||
|
||||
var count = 0;
|
||||
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel, ref Health hp, ref Frozen f) =>
|
||||
foreach (var it in world.Select<Position, Velocity, Health, Frozen>())
|
||||
{
|
||||
count++;
|
||||
pos.X.Should().Be(1);
|
||||
vel.Y.Should().Be(4);
|
||||
hp.Value.Should().Be(100);
|
||||
});
|
||||
it.Val1.X.Should().Be(1);
|
||||
it.Val2.Y.Should().Be(4);
|
||||
it.Val3.Value.Should().Be(100);
|
||||
}
|
||||
count.Should().Be(1);
|
||||
}
|
||||
|
||||
@@ -231,15 +187,11 @@ public class EdgeCaseTests
|
||||
world.AddComponent(entity, new Frozen());
|
||||
world.AddComponent(entity, new Burning());
|
||||
|
||||
var query = world.Query()
|
||||
.With<Position>().With<Velocity>().With<Health>().With<Frozen>().With<Burning>()
|
||||
.Build();
|
||||
|
||||
var count = 0;
|
||||
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel, ref Health hp, ref Frozen f, ref Burning b) =>
|
||||
foreach (var it in world.Select<Position, Velocity, Health, Frozen, Burning>())
|
||||
{
|
||||
count++;
|
||||
});
|
||||
}
|
||||
count.Should().Be(1);
|
||||
}
|
||||
|
||||
@@ -255,17 +207,11 @@ public class EdgeCaseTests
|
||||
world.AddComponent(entity, new Burning());
|
||||
world.AddComponent(entity, new Poisoned());
|
||||
|
||||
var query = world.Query()
|
||||
.With<Position>().With<Velocity>().With<Health>()
|
||||
.With<Frozen>().With<Burning>().With<Poisoned>()
|
||||
.Build();
|
||||
|
||||
var count = 0;
|
||||
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel, ref Health hp,
|
||||
ref Frozen f, ref Burning b, ref Poisoned p) =>
|
||||
foreach (var it in world.Select<Position, Velocity, Health, Frozen, Burning, Poisoned>())
|
||||
{
|
||||
count++;
|
||||
});
|
||||
}
|
||||
count.Should().Be(1);
|
||||
}
|
||||
|
||||
@@ -283,13 +229,13 @@ public class EdgeCaseTests
|
||||
world.AddComponent(b, new Frozen());
|
||||
|
||||
// Without<Frozen> filters out entity b which has Frozen.
|
||||
var query = world.Query().Without<Frozen>().Build();
|
||||
var query = new Query<Position>().Without<Frozen>();
|
||||
var results = new List<Entity>();
|
||||
|
||||
world.ForEach(query, (Entity e, ref Position pos) =>
|
||||
foreach (var it in world.Select(query))
|
||||
{
|
||||
results.Add(e);
|
||||
});
|
||||
results.Add(it.Entity);
|
||||
}
|
||||
|
||||
results.Should().BeEquivalentTo([a]);
|
||||
}
|
||||
@@ -417,16 +363,15 @@ public class EdgeCaseTests
|
||||
world.AddComponent(a, new Position { X = 1, Y = 2 });
|
||||
world.AddComponent(b, new Position { X = 3, Y = 4 });
|
||||
|
||||
var query = world.Query().With<Position>().Build();
|
||||
var seen = new List<Entity>();
|
||||
|
||||
world.ForEach(query, (Entity e, ref Position pos) =>
|
||||
foreach (var it in world.Select<Position>())
|
||||
{
|
||||
seen.Add(e);
|
||||
seen.Add(it.Entity);
|
||||
// Add a component to the other entity during iteration.
|
||||
// This should not affect the current iteration.
|
||||
world.AddComponent(e, new Velocity { X = 1, Y = 1 });
|
||||
});
|
||||
world.AddComponent(it.Entity, new Velocity { X = 1, Y = 1 });
|
||||
}
|
||||
|
||||
seen.Should().BeEquivalentTo([a, b]);
|
||||
}
|
||||
@@ -440,96 +385,123 @@ public class EdgeCaseTests
|
||||
world.AddComponent(a, new Position { X = 1, Y = 2 });
|
||||
world.AddComponent(b, new Position { X = 3, Y = 4 });
|
||||
|
||||
var query = world.Query().With<Position>().Build();
|
||||
|
||||
// Destroying an entity during iteration: the sparse set uses
|
||||
// swap-remove, so the destroyed entity's slot is replaced by
|
||||
// the last element. This is safe as long as we don't re-iterate
|
||||
// the destroyed entity (which we won't since it's swap-removed).
|
||||
var act = () =>
|
||||
{
|
||||
world.ForEach(query, (Entity e, ref Position pos) =>
|
||||
foreach (var it in world.Select<Position>())
|
||||
{
|
||||
world.DestroyEntity(e);
|
||||
});
|
||||
world.DestroyEntity(it.Entity);
|
||||
}
|
||||
};
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
// ── QueryDescriptor.With enforced during ForEach ─────────────────
|
||||
// ── ReorderSources edge cases ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ForEach_Throws_WhenTypeParamsDivergeFromQueryWith()
|
||||
public void ReorderSources_WithDuplicateEntities_ReordersToMatch()
|
||||
{
|
||||
var world = new World();
|
||||
var entity = world.CreateEntity();
|
||||
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||
world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
|
||||
var target = world.CreateEntity();
|
||||
|
||||
// Build a query requiring Position + Velocity, but iterate only Position.
|
||||
var query = world.Query().With<Position>().With<Velocity>().Build();
|
||||
var a = world.CreateEntity();
|
||||
var b = world.CreateEntity();
|
||||
|
||||
var act = () =>
|
||||
{
|
||||
world.ForEach(query, (Entity e, ref Position pos) => { });
|
||||
};
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*ForEach*type*Position*Velocity*");
|
||||
world.AddComponent(a, new Relationship<ChildOf, ParentOf> { Target = target });
|
||||
world.AddComponent(b, new Relationship<ChildOf, ParentOf> { Target = target });
|
||||
|
||||
// Reorder with duplicate entries — should still work (last occurrence wins).
|
||||
world.ReorderSources<Relationship<ChildOf, ParentOf>>(target, [b, a, b]);
|
||||
|
||||
var result = world.GetSources<Relationship<ChildOf, ParentOf>>(target);
|
||||
result.Should().Equal([b, a, b]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForEach_Throws_WhenTypeParamsAreSubsetOfQueryWith()
|
||||
public void ReorderSources_WithMissingEntity_DoesNotThrow()
|
||||
{
|
||||
var world = new World();
|
||||
var target = world.CreateEntity();
|
||||
|
||||
var a = world.CreateEntity();
|
||||
world.AddComponent(a, new Relationship<ChildOf, ParentOf> { Target = target });
|
||||
|
||||
// Reorder with an entity not in the set — the missing entity is simply
|
||||
// absent from the reordered result.
|
||||
var extra = world.CreateEntity();
|
||||
world.ReorderSources<Relationship<ChildOf, ParentOf>>(target, [extra, a]);
|
||||
|
||||
var result = world.GetSources<Relationship<ChildOf, ParentOf>>(target);
|
||||
result.Should().Equal([extra, a]);
|
||||
}
|
||||
|
||||
// ── RemoveSingleton during batching ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RemoveSingleton_DuringForEach_IsDeferred()
|
||||
{
|
||||
var world = new World();
|
||||
world.SetSingleton(new Position { X = 1, Y = 2 });
|
||||
|
||||
var entity = world.CreateEntity();
|
||||
world.AddComponent(entity, new Position { X = 3, Y = 4 });
|
||||
|
||||
var seen = new List<Entity>();
|
||||
foreach (var it in world.Select<Position>())
|
||||
{
|
||||
seen.Add(it.Entity);
|
||||
// Remove the singleton during iteration — should be deferred.
|
||||
world.RemoveSingleton<Position>();
|
||||
}
|
||||
|
||||
// The singleton entity should still have been iterated (deferred removal).
|
||||
seen.Should().HaveCount(1);
|
||||
seen[0].Should().Be(entity);
|
||||
|
||||
// After the foreach scope ends, the singleton removal is applied.
|
||||
world.HasSingleton<Position>().Should().BeFalse();
|
||||
}
|
||||
|
||||
// ── ReadComponent / ReadSingleton do not auto-track ───────────────
|
||||
|
||||
[Fact]
|
||||
public void ReadComponent_DoesNotAutoMarkModified()
|
||||
{
|
||||
var world = new World();
|
||||
var entity = world.CreateEntity();
|
||||
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||
world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
|
||||
world.AddComponent(entity, new Health { Value = 100 });
|
||||
world.PostChanges(); // Clear pending
|
||||
|
||||
// Build a query requiring 3 components, but iterate only 2.
|
||||
var query = world.Query().With<Position>().With<Velocity>().With<Health>().Build();
|
||||
var collector = new ChangeCollector();
|
||||
using var sub = world.ObserveComponentChanges<Position>().Subscribe(collector.ToObserver());
|
||||
|
||||
var act = () =>
|
||||
{
|
||||
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => { });
|
||||
};
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*ForEach*type*Health*");
|
||||
// Run a system that only reads via ReadComponent — no auto-marking.
|
||||
var group = new SystemGroup(world);
|
||||
group.Add(new ReadOnlySystem(world, entity));
|
||||
group.RunLogical();
|
||||
|
||||
collector.Changes.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForEach_Throws_WhenTypeParamsAreSupersetOfQueryWith()
|
||||
public void ReadSingleton_DoesNotAutoMarkModified()
|
||||
{
|
||||
var world = new World();
|
||||
var entity = world.CreateEntity();
|
||||
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||
world.SetSingleton(new Position { X = 1, Y = 2 });
|
||||
world.PostChanges(); // Clear pending
|
||||
|
||||
// Build a query requiring only Position, but iterate Position + Velocity.
|
||||
var query = world.Query().With<Position>().Build();
|
||||
var collector = new ChangeCollector();
|
||||
using var sub = world.ObserveComponentChanges<Position>().Subscribe(collector.ToObserver());
|
||||
|
||||
var act = () =>
|
||||
{
|
||||
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => { });
|
||||
};
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*ForEach*type*Velocity*");
|
||||
}
|
||||
// Run a system that only reads the singleton via ReadSingleton.
|
||||
var group = new SystemGroup(world);
|
||||
group.Add(new ReadSingletonSystem(world));
|
||||
group.RunLogical();
|
||||
|
||||
[Fact]
|
||||
public void ForEach_DoesNotThrow_WhenTypeParamsMatchQueryWith()
|
||||
{
|
||||
var world = new World();
|
||||
var entity = world.CreateEntity();
|
||||
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||
world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
|
||||
|
||||
var query = world.Query().With<Position>().With<Velocity>().Build();
|
||||
|
||||
var act = () =>
|
||||
{
|
||||
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => { });
|
||||
};
|
||||
act.Should().NotThrow();
|
||||
collector.Changes.Should().BeEmpty();
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
@@ -546,4 +518,39 @@ public class EdgeCaseTests
|
||||
// Phantom types for relationship tests.
|
||||
public struct ChildOf { }
|
||||
public struct ParentOf { }
|
||||
|
||||
// Helper systems for ReadComponent/ReadSingleton tests.
|
||||
private class ReadOnlySystem : ISystem
|
||||
{
|
||||
private readonly World _world;
|
||||
private readonly Entity _entity;
|
||||
|
||||
public ReadOnlySystem(World world, Entity entity)
|
||||
{
|
||||
_world = world;
|
||||
_entity = entity;
|
||||
}
|
||||
|
||||
public void RunImpl(World world)
|
||||
{
|
||||
var val = _world.ReadComponent<Position>(_entity);
|
||||
_ = val.X;
|
||||
}
|
||||
}
|
||||
|
||||
private class ReadSingletonSystem : ISystem
|
||||
{
|
||||
private readonly World _world;
|
||||
|
||||
public ReadSingletonSystem(World world)
|
||||
{
|
||||
_world = world;
|
||||
}
|
||||
|
||||
public void RunImpl(World world)
|
||||
{
|
||||
var val = _world.ReadSingleton<Position>();
|
||||
_ = val.X;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace OECS.Tests;
|
||||
|
||||
public class InterruptTests
|
||||
{
|
||||
// ── Test types ──
|
||||
|
||||
private record struct TestInterrupt : IInterrupt
|
||||
{
|
||||
public string Message;
|
||||
}
|
||||
|
||||
[MessagePack.MessagePackObject]
|
||||
private struct TestHandler : IInterruptHandlerCommand<TestInterrupt>
|
||||
{
|
||||
[MessagePack.Key(0)]
|
||||
public bool ShouldResolve;
|
||||
|
||||
public bool TryResolve(TestInterrupt interrupt)
|
||||
{
|
||||
return ShouldResolve;
|
||||
}
|
||||
}
|
||||
|
||||
private record struct AnotherInterrupt : IInterrupt
|
||||
{
|
||||
public int Value;
|
||||
}
|
||||
|
||||
[MessagePack.MessagePackObject]
|
||||
private struct AnotherHandler : IInterruptHandlerCommand<AnotherInterrupt>
|
||||
{
|
||||
[MessagePack.Key(0)]
|
||||
public bool ShouldResolve;
|
||||
|
||||
public bool TryResolve(AnotherInterrupt interrupt)
|
||||
{
|
||||
return ShouldResolve;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Issue and resolve ──
|
||||
|
||||
[Fact]
|
||||
public void Interrupt_blocks_next_tick()
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
|
||||
var systemRan = false;
|
||||
group.Add(new TestSystem(() => systemRan = true));
|
||||
|
||||
// Issue an interrupt.
|
||||
world.Interrupt(new TestInterrupt { Message = "hello" });
|
||||
|
||||
// Run a tick — systems should be skipped.
|
||||
group.RunLogical();
|
||||
|
||||
systemRan.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Interrupt_resolved_by_handler_unblocks_world()
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
|
||||
var systemRan = false;
|
||||
group.Add(new TestSystem(() => systemRan = true));
|
||||
|
||||
// Issue an interrupt.
|
||||
world.Interrupt(new TestInterrupt { Message = "hello" });
|
||||
group.RunLogical();
|
||||
systemRan.Should().BeFalse();
|
||||
|
||||
// Enqueue a handler that resolves it.
|
||||
world.Commands.Enqueue(new TestHandler { ShouldResolve = true });
|
||||
group.RunLogical();
|
||||
systemRan.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Interrupt_handler_rejects_leaves_interrupt_pending()
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
|
||||
var systemRan = false;
|
||||
group.Add(new TestSystem(() => systemRan = true));
|
||||
|
||||
world.Interrupt(new TestInterrupt { Message = "hello" });
|
||||
group.RunLogical();
|
||||
systemRan.Should().BeFalse();
|
||||
|
||||
// Handler rejects — interrupt stays pending.
|
||||
world.Commands.Enqueue(new TestHandler { ShouldResolve = false });
|
||||
group.RunLogical();
|
||||
systemRan.Should().BeFalse();
|
||||
|
||||
// Second handler accepts.
|
||||
world.Commands.Enqueue(new TestHandler { ShouldResolve = true });
|
||||
group.RunLogical();
|
||||
systemRan.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasInterrupt_returns_true_for_pending_interrupt()
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
|
||||
world.HasInterrupt<TestInterrupt>().Should().BeFalse();
|
||||
|
||||
world.Interrupt(new TestInterrupt { Message = "hello" });
|
||||
world.HasInterrupt<TestInterrupt>().Should().BeTrue();
|
||||
|
||||
world.Commands.Enqueue(new TestHandler { ShouldResolve = true });
|
||||
group.RunLogical();
|
||||
|
||||
world.HasInterrupt<TestInterrupt>().Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Duplicate_interrupt_type_throws()
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
|
||||
world.Interrupt(new TestInterrupt { Message = "first" });
|
||||
|
||||
var act = () => world.Interrupt(new TestInterrupt { Message = "second" });
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*TestInterrupt*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_interrupt_types_independent()
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
|
||||
world.Interrupt(new TestInterrupt { Message = "a" });
|
||||
world.Interrupt(new AnotherInterrupt { Value = 42 });
|
||||
|
||||
// Resolve only one.
|
||||
world.Commands.Enqueue(new TestHandler { ShouldResolve = true });
|
||||
group.RunLogical();
|
||||
|
||||
// TestInterrupt resolved, AnotherInterrupt still pending.
|
||||
world.HasInterrupt<TestInterrupt>().Should().BeFalse();
|
||||
world.HasInterrupt<AnotherInterrupt>().Should().BeTrue();
|
||||
|
||||
// Resolve the other.
|
||||
world.Commands.Enqueue(new AnotherHandler { ShouldResolve = true });
|
||||
group.RunLogical();
|
||||
|
||||
world.HasInterrupt<AnotherInterrupt>().Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handler_receives_correct_interrupt_data()
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
|
||||
world.Interrupt(new TestInterrupt { Message = "hello" });
|
||||
|
||||
// Enqueue handler and run — the default Execute calls TryResolve
|
||||
// with the interrupt data.
|
||||
world.Commands.Enqueue(new TestHandler { ShouldResolve = true });
|
||||
group.RunLogical();
|
||||
|
||||
// Interrupt was resolved — the TryResolve received the correct data.
|
||||
world.HasInterrupt<TestInterrupt>().Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Interrupt_without_system_group_is_noop()
|
||||
{
|
||||
var world = new World();
|
||||
|
||||
// No SystemGroup — interrupt is silently ignored.
|
||||
world.Interrupt(new TestInterrupt { Message = "hello" });
|
||||
world.HasInterrupt<TestInterrupt>().Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Commands_still_execute_when_blocked()
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
|
||||
var commandExecuted = false;
|
||||
var handler = new TestHandler { ShouldResolve = true };
|
||||
world.Interrupt(new TestInterrupt { Message = "hello" });
|
||||
|
||||
// Enqueue both a handler and a regular command.
|
||||
world.Commands.Enqueue(new TestHandler { ShouldResolve = true });
|
||||
world.Commands.Enqueue(new SetFlagCommand(() => commandExecuted = true));
|
||||
|
||||
group.RunLogical();
|
||||
|
||||
// Both should have executed.
|
||||
world.HasInterrupt<TestInterrupt>().Should().BeFalse();
|
||||
commandExecuted.Should().BeTrue();
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
private class TestSystem : ISystem
|
||||
{
|
||||
private readonly Action _action;
|
||||
public TestSystem(Action action) => _action = action;
|
||||
public void RunImpl(World world) => _action();
|
||||
}
|
||||
|
||||
[MessagePack.MessagePackObject]
|
||||
private struct SetFlagCommand : ICommand
|
||||
{
|
||||
private Action? _action;
|
||||
|
||||
[MessagePack.IgnoreMember]
|
||||
public Action Action
|
||||
{
|
||||
get => _action!;
|
||||
set => _action = value;
|
||||
}
|
||||
|
||||
public SetFlagCommand(Action action) => _action = action;
|
||||
|
||||
public void Execute(World world) => _action?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ public class ReactivityTests
|
||||
private struct Position { public float X; public float Y; }
|
||||
private struct Health { public int Value; }
|
||||
private struct Frozen { }
|
||||
private struct ChildOf { }
|
||||
private struct ParentOf { }
|
||||
|
||||
/// <summary>
|
||||
/// Helper that collects changes into a list and exposes an R3 Observer.
|
||||
@@ -208,7 +210,7 @@ public class ReactivityTests
|
||||
public void ObserveQuery_OnlyReceivesMatchingChanges()
|
||||
{
|
||||
var world = new World();
|
||||
var query = world.Query().With<Position>().Without<Frozen>().Build();
|
||||
var query = new Query<Position>().Without<Frozen>();
|
||||
var collector = new ChangeCollector();
|
||||
|
||||
using var sub = world.ObserveQuery(query).Subscribe(collector.ToObserver());
|
||||
@@ -264,6 +266,31 @@ public class ReactivityTests
|
||||
collector.Changes.Should().Contain(c => c.Kind == ChangeKind.EntityAdded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReorderSources_PostsRelationshipReordered()
|
||||
{
|
||||
var world = new World();
|
||||
var parent = world.CreateEntity();
|
||||
var a = world.CreateEntity();
|
||||
var b = world.CreateEntity();
|
||||
|
||||
world.AddComponent(a, new Relationship<ChildOf, ParentOf> { Target = parent });
|
||||
world.AddComponent(b, new Relationship<ChildOf, ParentOf> { Target = parent });
|
||||
world.PostChanges(); // Clear pending
|
||||
|
||||
var collector = new ChangeCollector();
|
||||
using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver());
|
||||
|
||||
world.ReorderSources<Relationship<ChildOf, ParentOf>>(parent, [b, a]);
|
||||
world.PostChanges();
|
||||
|
||||
collector.Changes.Should().ContainSingle()
|
||||
.Which.Should().Match<EntityChange>(c =>
|
||||
c.Entity == parent &&
|
||||
c.Kind == ChangeKind.RelationshipReordered &&
|
||||
c.ComponentType == typeof(Relationship<ChildOf, ParentOf>));
|
||||
}
|
||||
|
||||
private class EntityCreatorSystem : ISystem
|
||||
{
|
||||
private readonly World _world;
|
||||
@@ -273,7 +300,7 @@ public class ReactivityTests
|
||||
_world = world;
|
||||
}
|
||||
|
||||
public void Run(World world)
|
||||
public void RunImpl(World world)
|
||||
{
|
||||
_world.CreateEntity();
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ public class RelationshipTests
|
||||
|
||||
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child,
|
||||
Target = parent
|
||||
});
|
||||
|
||||
@@ -41,7 +40,6 @@ public class RelationshipTests
|
||||
|
||||
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child,
|
||||
Target = parent
|
||||
});
|
||||
|
||||
@@ -61,14 +59,12 @@ public class RelationshipTests
|
||||
|
||||
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child,
|
||||
Target = parent1
|
||||
});
|
||||
|
||||
// Replace with a new target.
|
||||
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child,
|
||||
Target = parent2
|
||||
});
|
||||
|
||||
@@ -85,7 +81,6 @@ public class RelationshipTests
|
||||
|
||||
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child,
|
||||
Target = parent
|
||||
});
|
||||
|
||||
@@ -107,7 +102,6 @@ public class RelationshipTests
|
||||
|
||||
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child,
|
||||
Target = parent
|
||||
});
|
||||
|
||||
@@ -131,15 +125,15 @@ public class RelationshipTests
|
||||
|
||||
world.AddComponent(child1, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child1, Target = parent
|
||||
Target = parent
|
||||
});
|
||||
world.AddComponent(child2, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child2, Target = parent
|
||||
Target = parent
|
||||
});
|
||||
world.AddComponent(child3, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child3, Target = parent
|
||||
Target = parent
|
||||
});
|
||||
|
||||
var sources = world.GetSources<Relationship<ChildOf, ParentOf>>(parent);
|
||||
@@ -156,11 +150,11 @@ public class RelationshipTests
|
||||
|
||||
world.AddComponent(entity, new Relationship<OwnedBy, Thing>
|
||||
{
|
||||
Source = entity, Target = owner
|
||||
Target = owner
|
||||
});
|
||||
world.AddComponent(entity, new Relationship<MemberOf, Thing>
|
||||
{
|
||||
Source = entity, Target = group
|
||||
Target = group
|
||||
});
|
||||
|
||||
world.GetSources<Relationship<OwnedBy, Thing>>(owner).Should().BeEquivalentTo([entity]);
|
||||
@@ -189,21 +183,94 @@ public class RelationshipTests
|
||||
|
||||
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child, Target = parent
|
||||
Target = parent
|
||||
});
|
||||
|
||||
var query = world.Query().With<Relationship<ChildOf, ParentOf>>().Build();
|
||||
var results = new List<Entity>();
|
||||
|
||||
world.ForEach(query, (Entity e, ref Relationship<ChildOf, ParentOf> rel) =>
|
||||
foreach (var it in world.Select<Relationship<ChildOf, ParentOf>>())
|
||||
{
|
||||
results.Add(e);
|
||||
rel.Target.Should().Be(parent);
|
||||
});
|
||||
results.Add(it.Entity);
|
||||
it.Val1.Target.Should().Be(parent);
|
||||
}
|
||||
|
||||
results.Should().BeEquivalentTo([child]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSources_PreservesInsertionOrder()
|
||||
{
|
||||
var world = new World();
|
||||
var parent = world.CreateEntity();
|
||||
|
||||
// Add sources in a specific order.
|
||||
var sources = new Entity[5];
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
sources[i] = world.CreateEntity();
|
||||
world.AddComponent(sources[i], new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Target = parent
|
||||
});
|
||||
}
|
||||
|
||||
var result = world.GetSources<Relationship<ChildOf, ParentOf>>(parent);
|
||||
result.Should().Equal(sources);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSources_OrderIsStableAfterMiddleRemove()
|
||||
{
|
||||
var world = new World();
|
||||
var parent = world.CreateEntity();
|
||||
|
||||
var a = world.CreateEntity();
|
||||
var b = world.CreateEntity();
|
||||
var c = world.CreateEntity();
|
||||
|
||||
world.AddComponent(a, new Relationship<ChildOf, ParentOf> { Target = parent });
|
||||
world.AddComponent(b, new Relationship<ChildOf, ParentOf> { Target = parent });
|
||||
world.AddComponent(c, new Relationship<ChildOf, ParentOf> { Target = parent });
|
||||
|
||||
// Remove the middle source.
|
||||
world.RemoveComponent<Relationship<ChildOf, ParentOf>>(b);
|
||||
|
||||
// Remaining sources should preserve insertion order.
|
||||
var result = world.GetSources<Relationship<ChildOf, ParentOf>>(parent);
|
||||
result.Should().Equal([a, c]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReorderSources_ChangesIterationOrder()
|
||||
{
|
||||
var world = new World();
|
||||
var parent = world.CreateEntity();
|
||||
|
||||
var a = world.CreateEntity();
|
||||
var b = world.CreateEntity();
|
||||
var c = world.CreateEntity();
|
||||
|
||||
world.AddComponent(a, new Relationship<ChildOf, ParentOf> { Target = parent });
|
||||
world.AddComponent(b, new Relationship<ChildOf, ParentOf> { Target = parent });
|
||||
world.AddComponent(c, new Relationship<ChildOf, ParentOf> { Target = parent });
|
||||
|
||||
// Reverse the order.
|
||||
world.ReorderSources<Relationship<ChildOf, ParentOf>>(parent, [c, b, a]);
|
||||
|
||||
var result = world.GetSources<Relationship<ChildOf, ParentOf>>(parent);
|
||||
result.Should().Equal([c, b, a]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReorderSources_NoopsWhenTargetHasNoRelationships()
|
||||
{
|
||||
var world = new World();
|
||||
var entity = world.CreateEntity();
|
||||
|
||||
// Should not throw.
|
||||
world.ReorderSources<Relationship<ChildOf, ParentOf>>(entity, []);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DestroyEntity_WithBothIncomingAndOutgoingRelationships()
|
||||
{
|
||||
@@ -215,13 +282,13 @@ public class RelationshipTests
|
||||
// a → b (a is child of b)
|
||||
world.AddComponent(a, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = a, Target = b
|
||||
Target = b
|
||||
});
|
||||
|
||||
// c → a (c is child of a)
|
||||
world.AddComponent(c, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = c, Target = a
|
||||
Target = a
|
||||
});
|
||||
|
||||
// Destroy a. This should:
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
using FluentAssertions;
|
||||
using MessagePack;
|
||||
using Xunit;
|
||||
|
||||
namespace OECS.Tests;
|
||||
|
||||
// ── Test components for serialization ─────────────────────────────────
|
||||
|
||||
[MessagePackObject]
|
||||
public struct SerialPos : IMessagePackSerializationCallbackReceiver
|
||||
{
|
||||
[Key(0)] public float X { get; set; }
|
||||
[Key(1)] public float Y { get; set; }
|
||||
|
||||
public void OnBeforeSerialize() { }
|
||||
public void OnAfterDeserialize() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public struct SerialHealth : IMessagePackSerializationCallbackReceiver
|
||||
{
|
||||
[Key(0)] public int Value { get; set; }
|
||||
|
||||
public void OnBeforeSerialize() { }
|
||||
public void OnAfterDeserialize() { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public struct SerialConfig : IMessagePackSerializationCallbackReceiver
|
||||
{
|
||||
[Key(0)] public float Gravity { get; set; }
|
||||
[Key(1)] public int MaxPlayers { get; set; }
|
||||
|
||||
public void OnBeforeSerialize() { }
|
||||
public void OnAfterDeserialize() { }
|
||||
}
|
||||
|
||||
// ── Phantom types for relationship serialization ──────────────────────
|
||||
|
||||
public struct SerialChildOf { }
|
||||
public struct SerialParentOf { }
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
public class SerializationTests
|
||||
{
|
||||
[Fact]
|
||||
public void SaveLoad_RoundTrip_PreservesComponents()
|
||||
{
|
||||
var world = new World();
|
||||
var entity = world.CreateEntity();
|
||||
world.AddComponent(entity, new SerialPos { X = 1.5f, Y = 2.5f });
|
||||
world.AddComponent(entity, new SerialHealth { Value = 100 });
|
||||
|
||||
var stream = new MemoryStream();
|
||||
WorldSerializer.Save(world, stream);
|
||||
|
||||
// Load into a fresh world.
|
||||
stream.Position = 0;
|
||||
var world2 = new World();
|
||||
WorldSerializer.Load(world2, stream);
|
||||
|
||||
// Find the entity in the loaded world.
|
||||
var found = world2.FindEntity<SerialPos, SerialHealth>();
|
||||
found.Should().NotBe(Entity.Null);
|
||||
|
||||
ref var pos = ref world2.GetComponent<SerialPos>(found);
|
||||
pos.X.Should().Be(1.5f);
|
||||
pos.Y.Should().Be(2.5f);
|
||||
|
||||
ref var hp = ref world2.GetComponent<SerialHealth>(found);
|
||||
hp.Value.Should().Be(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveLoad_RoundTrip_PreservesMultipleEntities()
|
||||
{
|
||||
var world = new World();
|
||||
var a = world.CreateEntity();
|
||||
var b = world.CreateEntity();
|
||||
world.AddComponent(a, new SerialPos { X = 1, Y = 2 });
|
||||
world.AddComponent(a, new SerialHealth { Value = 10 });
|
||||
world.AddComponent(b, new SerialPos { X = 3, Y = 4 });
|
||||
world.AddComponent(b, new SerialHealth { Value = 20 });
|
||||
|
||||
var stream = new MemoryStream();
|
||||
WorldSerializer.Save(world, stream);
|
||||
|
||||
stream.Position = 0;
|
||||
var world2 = new World();
|
||||
WorldSerializer.Load(world2, stream);
|
||||
|
||||
var positions = new List<(float X, float Y, int Health)>();
|
||||
foreach (var it in world2.Select<SerialPos, SerialHealth>())
|
||||
{
|
||||
positions.Add((it.Val1.X, it.Val1.Y, it.Val2.Value));
|
||||
}
|
||||
|
||||
positions.Should().BeEquivalentTo([(1, 2, 10), (3, 4, 20)]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveLoad_RoundTrip_PreservesEntityIds()
|
||||
{
|
||||
var world = new World();
|
||||
var entity = world.CreateEntity();
|
||||
world.AddComponent(entity, new SerialPos { X = 1, Y = 2 });
|
||||
|
||||
var stream = new MemoryStream();
|
||||
WorldSerializer.Save(world, stream);
|
||||
|
||||
stream.Position = 0;
|
||||
var world2 = new World();
|
||||
WorldSerializer.Load(world2, stream);
|
||||
|
||||
var found = world2.FindEntity<SerialPos>();
|
||||
found.Id.Should().Be(entity.Id);
|
||||
found.Version.Should().Be(entity.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveLoad_RoundTrip_PreservesSingletons()
|
||||
{
|
||||
var world = new World();
|
||||
world.SetSingleton(new SerialConfig { Gravity = 9.8f, MaxPlayers = 4 });
|
||||
|
||||
var stream = new MemoryStream();
|
||||
WorldSerializer.Save(world, stream);
|
||||
|
||||
stream.Position = 0;
|
||||
var world2 = new World();
|
||||
WorldSerializer.Load(world2, stream);
|
||||
|
||||
world2.HasSingleton<SerialConfig>().Should().BeTrue();
|
||||
ref var config = ref world2.GetSingleton<SerialConfig>();
|
||||
config.Gravity.Should().Be(9.8f);
|
||||
config.MaxPlayers.Should().Be(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveLoad_RoundTrip_PreservesRelationships()
|
||||
{
|
||||
var world = new World();
|
||||
var child = world.CreateEntity();
|
||||
var parent = world.CreateEntity();
|
||||
world.AddComponent(child, new Relationship<SerialChildOf, SerialParentOf>
|
||||
{
|
||||
Target = parent
|
||||
});
|
||||
|
||||
var stream = new MemoryStream();
|
||||
WorldSerializer.Save(world, stream);
|
||||
|
||||
stream.Position = 0;
|
||||
var world2 = new World();
|
||||
WorldSerializer.Load(world2, stream);
|
||||
|
||||
// Find the relationship.
|
||||
var found = world2.FindEntity<Relationship<SerialChildOf, SerialParentOf>>();
|
||||
found.Should().NotBe(Entity.Null);
|
||||
|
||||
ref var rel = ref world2.GetComponent<Relationship<SerialChildOf, SerialParentOf>>(found);
|
||||
rel.Target.Should().NotBe(Entity.Null);
|
||||
|
||||
// The target should be alive and have the same ID as the original parent.
|
||||
world2.IsAlive(rel.Target).Should().BeTrue();
|
||||
rel.Target.Id.Should().Be(parent.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveLoad_RoundTrip_PreservesRelationshipTargetIntegrity()
|
||||
{
|
||||
var world = new World();
|
||||
var child = world.CreateEntity();
|
||||
var parent = world.CreateEntity();
|
||||
world.AddComponent(parent, new SerialPos { X = 10, Y = 20 });
|
||||
world.AddComponent(child, new Relationship<SerialChildOf, SerialParentOf>
|
||||
{
|
||||
Target = parent
|
||||
});
|
||||
|
||||
var stream = new MemoryStream();
|
||||
WorldSerializer.Save(world, stream);
|
||||
|
||||
stream.Position = 0;
|
||||
var world2 = new World();
|
||||
WorldSerializer.Load(world2, stream);
|
||||
|
||||
var foundChild = world2.FindEntity<Relationship<SerialChildOf, SerialParentOf>>();
|
||||
ref var rel = ref world2.GetComponent<Relationship<SerialChildOf, SerialParentOf>>(foundChild);
|
||||
|
||||
// The target entity should still have its component.
|
||||
world2.HasComponent<SerialPos>(rel.Target).Should().BeTrue();
|
||||
ref var pos = ref world2.GetComponent<SerialPos>(rel.Target);
|
||||
pos.X.Should().Be(10);
|
||||
pos.Y.Should().Be(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveLoad_EmptyWorld_Works()
|
||||
{
|
||||
var world = new World();
|
||||
|
||||
var stream = new MemoryStream();
|
||||
WorldSerializer.Save(world, stream);
|
||||
|
||||
stream.Position = 0;
|
||||
var world2 = new World();
|
||||
WorldSerializer.Load(world2, stream);
|
||||
|
||||
// Loading an empty world should not throw.
|
||||
var count = 0;
|
||||
foreach (var it in world2.Select<SerialPos>())
|
||||
count++;
|
||||
count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveLoad_EntityWithNoComponents_IsNotSerialized()
|
||||
{
|
||||
var world = new World();
|
||||
world.CreateEntity(); // Entity with no components.
|
||||
|
||||
var stream = new MemoryStream();
|
||||
WorldSerializer.Save(world, stream);
|
||||
|
||||
stream.Position = 0;
|
||||
var world2 = new World();
|
||||
WorldSerializer.Load(world2, stream);
|
||||
|
||||
// No entities should be loaded since the empty entity wasn't serialized.
|
||||
var count = 0;
|
||||
foreach (var it in world2.Select<SerialPos>())
|
||||
count++;
|
||||
count.Should().Be(0);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,7 +93,6 @@ 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
|
||||
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
|
||||
@@ -111,6 +117,42 @@ Global
|
||||
{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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
+94
-17
@@ -15,7 +15,7 @@ internal class ChangeBuffer
|
||||
|
||||
private readonly Subject<EntityChange> _entitySubject = new();
|
||||
private readonly Dictionary<Type, TrackedSubject> _componentSubjects = new();
|
||||
private readonly Dictionary<QueryDescriptor, TrackedSubject> _querySubjects = new();
|
||||
private readonly Dictionary<QueryKey, TrackedSubject> _querySubjects = new();
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a <see cref="Subject{T}"/> with a subscriber count so that
|
||||
@@ -27,6 +27,42 @@ internal class ChangeBuffer
|
||||
public int SubscriberCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifies a query subscription for cleanup tracking.
|
||||
/// </summary>
|
||||
private readonly struct QueryKey : IEquatable<QueryKey>
|
||||
{
|
||||
public readonly Type[] WithTypes;
|
||||
public readonly Type[] WithoutTypes;
|
||||
|
||||
public QueryKey(Type[] withTypes, Type[] withoutTypes)
|
||||
{
|
||||
WithTypes = withTypes;
|
||||
WithoutTypes = withoutTypes;
|
||||
}
|
||||
|
||||
public bool Equals(QueryKey other)
|
||||
{
|
||||
if (WithTypes.Length != other.WithTypes.Length) return false;
|
||||
if (WithoutTypes.Length != other.WithoutTypes.Length) return false;
|
||||
for (int i = 0; i < WithTypes.Length; i++)
|
||||
if (WithTypes[i] != other.WithTypes[i]) return false;
|
||||
for (int i = 0; i < WithoutTypes.Length; i++)
|
||||
if (WithoutTypes[i] != other.WithoutTypes[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj) => obj is QueryKey other && Equals(other);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var hash = new HashCode();
|
||||
foreach (var t in WithTypes) hash.Add(t);
|
||||
foreach (var t in WithoutTypes) hash.Add(t);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The change set currently accumulating. Cleared after each <see cref="Post"/>.
|
||||
/// </summary>
|
||||
@@ -101,14 +137,33 @@ internal class ChangeBuffer
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an observable that emits changes matching the given query.
|
||||
/// Returns an observable that emits component-level changes matching
|
||||
/// the given query's With types and excluding its Without types.
|
||||
/// </summary>
|
||||
public Observable<EntityChange> ObserveQuery(QueryDescriptor query)
|
||||
public Observable<EntityChange> ObserveQuery<T1>(Query<T1> query)
|
||||
where T1 : struct
|
||||
{
|
||||
if (!_querySubjects.TryGetValue(query, out var tracked))
|
||||
var key = MakeKey(query);
|
||||
return SubscribeQuery(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an observable that emits component-level changes matching
|
||||
/// the given query.
|
||||
/// </summary>
|
||||
public Observable<EntityChange> ObserveQuery<T1, T2>(Query<T1, T2> query)
|
||||
where T1 : struct where T2 : struct
|
||||
{
|
||||
var key = MakeKey(query);
|
||||
return SubscribeQuery(key);
|
||||
}
|
||||
|
||||
private Observable<EntityChange> SubscribeQuery(QueryKey key)
|
||||
{
|
||||
if (!_querySubjects.TryGetValue(key, out var tracked))
|
||||
{
|
||||
tracked = new TrackedSubject();
|
||||
_querySubjects[query] = tracked;
|
||||
_querySubjects[key] = tracked;
|
||||
}
|
||||
|
||||
tracked.SubscriberCount++;
|
||||
@@ -118,11 +173,28 @@ internal class ChangeBuffer
|
||||
if (tracked.SubscriberCount <= 0)
|
||||
{
|
||||
tracked.Subject.Dispose();
|
||||
_querySubjects.Remove(query);
|
||||
_querySubjects.Remove(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static QueryKey MakeKey<T1>(Query<T1> query) where T1 : struct
|
||||
{
|
||||
var without = query.WithoutTypes;
|
||||
return new QueryKey(
|
||||
[typeof(T1)],
|
||||
without != null ? [.. without] : []);
|
||||
}
|
||||
|
||||
private static QueryKey MakeKey<T1, T2>(Query<T1, T2> query)
|
||||
where T1 : struct where T2 : struct
|
||||
{
|
||||
var without = query.WithoutTypes;
|
||||
return new QueryKey(
|
||||
[typeof(T1), typeof(T2)],
|
||||
without != null ? [.. without] : []);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps an observable so that <paramref name="onLastDispose"/> is called
|
||||
/// when the last subscriber disposes.
|
||||
@@ -159,21 +231,26 @@ internal class ChangeBuffer
|
||||
_querySubjects.Clear();
|
||||
}
|
||||
|
||||
private static bool ChangeMatchesQuery(EntityChange change, QueryDescriptor query)
|
||||
private static bool ChangeMatchesQuery(EntityChange change, QueryKey query)
|
||||
{
|
||||
// Entity-level changes: match if the entity was added/removed and
|
||||
// the query has any "with" types (we can't know component state for
|
||||
// removed entities, so we only match added entities that would satisfy
|
||||
// the query — but we don't have component data here).
|
||||
// For simplicity, we only match component-level changes against queries.
|
||||
// Entity-level changes don't have a component type.
|
||||
if (change.ComponentType == null)
|
||||
return false;
|
||||
|
||||
// A component change matches a query if the component type is in the
|
||||
// query's With set and not in the Without set.
|
||||
if (query.Without.Contains(change.ComponentType))
|
||||
return false;
|
||||
// Must be in With types.
|
||||
bool inWith = false;
|
||||
foreach (var t in query.WithTypes)
|
||||
{
|
||||
if (t == change.ComponentType) { inWith = true; break; }
|
||||
}
|
||||
if (!inWith) return false;
|
||||
|
||||
return query.With.Contains(change.ComponentType);
|
||||
// Must not be in Without types.
|
||||
foreach (var t in query.WithoutTypes)
|
||||
{
|
||||
if (t == change.ComponentType) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ namespace OECS;
|
||||
/// use-after-free bugs when entity IDs are recycled.
|
||||
/// </summary>
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
[MessagePackFormatter(typeof(EntityFormatter))]
|
||||
public readonly partial struct Entity : IEquatable<Entity>
|
||||
{
|
||||
private const uint IdMask = 0x00FF_FFFF; // lower 24 bits
|
||||
@@ -57,6 +58,16 @@ public readonly partial struct Entity : IEquatable<Entity>
|
||||
/// </summary>
|
||||
internal Entity WithVersion(uint version) => new(Id, version);
|
||||
|
||||
/// <summary>
|
||||
/// Converts the entity to its raw 32-bit packed representation.
|
||||
/// </summary>
|
||||
public static explicit operator uint(Entity entity) => entity._value;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an entity from its raw 32-bit packed representation.
|
||||
/// </summary>
|
||||
public static explicit operator Entity(uint value) => new(value);
|
||||
|
||||
public bool Equals(Entity other) => _value == other._value;
|
||||
|
||||
public override bool Equals(object? obj) => obj is Entity other && Equals(other);
|
||||
|
||||
+1
-12
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using MessagePack;
|
||||
using MessagePack.Formatters;
|
||||
|
||||
namespace OECS;
|
||||
|
||||
/// <summary>
|
||||
/// Custom MessagePack formatter for <see cref="Entity"/>.
|
||||
///
|
||||
/// Reads and writes the raw 32-bit packed value as a single uint.
|
||||
/// This avoids the readonly-field issue where MessagePack's built-in
|
||||
/// deserialization path (reflection-based field assignment) cannot
|
||||
/// write to <c>readonly</c> fields on value types.
|
||||
/// </summary>
|
||||
public class EntityFormatter : IMessagePackFormatter<Entity>
|
||||
{
|
||||
public void Serialize(ref MessagePackWriter writer, Entity value, MessagePackSerializerOptions options)
|
||||
{
|
||||
writer.Write((uint)value);
|
||||
}
|
||||
|
||||
public Entity Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
|
||||
{
|
||||
return (Entity)reader.ReadUInt32();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace OECS;
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for interrupt types. An interrupt is a signal issued by a
|
||||
/// system that blocks the next tick until a matching <see cref="IInterruptHandlerCommand{TInterrupt}"/>
|
||||
/// resolves it.
|
||||
/// </summary>
|
||||
public interface IInterrupt { }
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace OECS;
|
||||
|
||||
/// <summary>
|
||||
/// A command that resolves a pending interrupt of type <typeparamref name="TInterrupt"/>.
|
||||
///
|
||||
/// The default <see cref="ICommand.Execute"/> implementation looks up the pending
|
||||
/// interrupt, calls <see cref="TryResolve"/> to let the handler inspect it, and
|
||||
/// resolves the interrupt if the handler returns <c>true</c>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInterrupt">The interrupt type this handler resolves.</typeparam>
|
||||
public interface IInterruptHandlerCommand<TInterrupt> : ICommand
|
||||
where TInterrupt : struct, IInterrupt
|
||||
{
|
||||
void ICommand.Execute(World world)
|
||||
{
|
||||
if (world.TryGetPendingInterrupt<TInterrupt>(out var interrupt))
|
||||
{
|
||||
if (TryResolve(interrupt))
|
||||
{
|
||||
world.ResolveInterrupt<TInterrupt>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by the default <see cref="ICommand.Execute"/> with the pending interrupt.
|
||||
/// Return <c>true</c> to resolve and remove the interrupt; <c>false</c> to leave it
|
||||
/// pending for another handler.
|
||||
/// </summary>
|
||||
bool TryResolve(TInterrupt interrupt);
|
||||
}
|
||||
@@ -2,19 +2,14 @@ namespace OECS;
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for components that represent a directed relationship
|
||||
/// between two entities. The component is stored on the <see cref="Source"/>
|
||||
/// entity and points to the <see cref="Target"/> entity.
|
||||
/// between two entities. The component is stored on the source entity
|
||||
/// (the entity it's added to) and points to the <see cref="Target"/> entity.
|
||||
///
|
||||
/// The <see cref="World"/> automatically maintains a reverse index so that
|
||||
/// all sources pointing to a given target can be looked up efficiently.
|
||||
/// </summary>
|
||||
public interface IRelationship
|
||||
{
|
||||
/// <summary>
|
||||
/// The entity that owns this relationship component.
|
||||
/// </summary>
|
||||
Entity Source { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The entity that this relationship points to.
|
||||
/// </summary>
|
||||
|
||||
+35
-3
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace OECS;
|
||||
|
||||
/// <summary>
|
||||
/// Internal store for pending interrupts. Owned by <see cref="SystemGroup"/>
|
||||
/// and injected into <see cref="World"/> so the handler's default Execute
|
||||
/// can access it.
|
||||
/// </summary>
|
||||
internal class InterruptStore
|
||||
{
|
||||
private readonly Dictionary<Type, object> _interrupts = new();
|
||||
|
||||
/// <summary>
|
||||
/// Stores an interrupt. Only one interrupt of a given type may be pending.
|
||||
/// </summary>
|
||||
public void Add<T>(T interrupt) where T : struct, IInterrupt
|
||||
{
|
||||
var type = typeof(T);
|
||||
if (_interrupts.ContainsKey(type))
|
||||
throw new InvalidOperationException(
|
||||
$"An interrupt of type {type.Name} is already pending.");
|
||||
|
||||
_interrupts[type] = interrupt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to retrieve a pending interrupt of type <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
public bool TryGet<T>(out T interrupt) where T : struct, IInterrupt
|
||||
{
|
||||
if (_interrupts.TryGetValue(typeof(T), out var boxed))
|
||||
{
|
||||
interrupt = (T)boxed;
|
||||
return true;
|
||||
}
|
||||
|
||||
interrupt = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the pending interrupt of type <typeparamref name="T"/>.
|
||||
/// No-op if none is pending.
|
||||
/// </summary>
|
||||
public bool Remove<T>() where T : struct, IInterrupt
|
||||
{
|
||||
return _interrupts.Remove(typeof(T));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if any interrupt is currently pending.
|
||||
/// </summary>
|
||||
public bool HasAny => _interrupts.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// True if an interrupt of type <typeparamref name="T"/> is pending.
|
||||
/// </summary>
|
||||
public bool Has<T>() where T : struct, IInterrupt
|
||||
{
|
||||
return _interrupts.ContainsKey(typeof(T));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all pending interrupts. Used during world reset or serialization load.
|
||||
/// </summary>
|
||||
internal void Clear()
|
||||
{
|
||||
_interrupts.Clear();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,100 @@
|
||||
namespace OECS;
|
||||
|
||||
/// <summary>
|
||||
/// Describes a query over the ECS world with one required component type.
|
||||
/// Add optional <c>Without</c> filters via the fluent API.
|
||||
/// </summary>
|
||||
public struct Query<T1> where T1 : struct
|
||||
{
|
||||
internal HashSet<Type>? WithoutTypes;
|
||||
|
||||
/// <summary>
|
||||
/// Excludes entities that have a component of type <typeparamref name="W"/>.
|
||||
/// </summary>
|
||||
public Query<T1> Without<W>() where W : struct
|
||||
{
|
||||
WithoutTypes ??= new HashSet<Type>();
|
||||
WithoutTypes.Add(typeof(W));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a query over the ECS world with two required component types.
|
||||
/// </summary>
|
||||
public struct Query<T1, T2> where T1 : struct where T2 : struct
|
||||
{
|
||||
internal HashSet<Type>? WithoutTypes;
|
||||
|
||||
public Query<T1, T2> Without<W>() where W : struct
|
||||
{
|
||||
WithoutTypes ??= new HashSet<Type>();
|
||||
WithoutTypes.Add(typeof(W));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a query over the ECS world with three required component types.
|
||||
/// </summary>
|
||||
public struct Query<T1, T2, T3>
|
||||
where T1 : struct where T2 : struct where T3 : struct
|
||||
{
|
||||
internal HashSet<Type>? WithoutTypes;
|
||||
|
||||
public Query<T1, T2, T3> Without<W>() where W : struct
|
||||
{
|
||||
WithoutTypes ??= new HashSet<Type>();
|
||||
WithoutTypes.Add(typeof(W));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a query over the ECS world with four required component types.
|
||||
/// </summary>
|
||||
public struct Query<T1, T2, T3, T4>
|
||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct
|
||||
{
|
||||
internal HashSet<Type>? WithoutTypes;
|
||||
|
||||
public Query<T1, T2, T3, T4> Without<W>() where W : struct
|
||||
{
|
||||
WithoutTypes ??= new HashSet<Type>();
|
||||
WithoutTypes.Add(typeof(W));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a query over the ECS world with five required component types.
|
||||
/// </summary>
|
||||
public struct Query<T1, T2, T3, T4, T5>
|
||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct
|
||||
{
|
||||
internal HashSet<Type>? WithoutTypes;
|
||||
|
||||
public Query<T1, T2, T3, T4, T5> Without<W>() where W : struct
|
||||
{
|
||||
WithoutTypes ??= new HashSet<Type>();
|
||||
WithoutTypes.Add(typeof(W));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a query over the ECS world with six required component types.
|
||||
/// </summary>
|
||||
public struct Query<T1, T2, T3, T4, T5, T6>
|
||||
where T1 : struct where T2 : struct where T3 : struct
|
||||
where T4 : struct where T5 : struct where T6 : struct
|
||||
{
|
||||
internal HashSet<Type>? WithoutTypes;
|
||||
|
||||
public Query<T1, T2, T3, T4, T5, T6> Without<W>() where W : struct
|
||||
{
|
||||
WithoutTypes ??= new HashSet<Type>();
|
||||
WithoutTypes.Add(typeof(W));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user