Compare commits
35
Commits
5594515a53
..
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 | ||
|
|
3b08138580 | ||
|
|
b066ac2eba | ||
|
|
10c4caed90 | ||
|
|
f8d0fe314c | ||
|
|
343bcd5b2e | ||
|
|
e1fb197474 | ||
|
|
b4661e714e | ||
|
|
7946f4bafd |
@@ -0,0 +1,204 @@
|
|||||||
|
---
|
||||||
|
name: developing-oecs
|
||||||
|
description: Develop and modify the OECS entity component system library itself. Use this when changing the core ECS types (World, Entity, ComponentStore, SparseSet, query execution, commands, relationships, reactivity, singletons, serialization) or the OECS.SourceGen incremental generator.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Developing OECS
|
||||||
|
|
||||||
|
OECS is a single-threaded, observable-first ECS for C#. It targets `net8.0`
|
||||||
|
(C# 12). The source lives under `OECS/` and the incremental source generator is
|
||||||
|
under `OECS.SourceGen/`.
|
||||||
|
|
||||||
|
## Project Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
OECS.sln
|
||||||
|
├── Directory.Build.props # Shared: net8.0, ImplicitUsings, Nullable, C# 12
|
||||||
|
├── OECS/
|
||||||
|
│ └── OECS.csproj # Core library + NuGet metadata
|
||||||
|
├── OECS.SourceGen/
|
||||||
|
│ └── OECS.SourceGen.csproj # Roslyn incremental source generator
|
||||||
|
├── OECS.Tests/ # Unit tests for OECS itself
|
||||||
|
├── Game.Blackjack/ # Integration test: blackjack game
|
||||||
|
├── Game.Blackjack.Tests/ # Blackjack tests (unit + snapshot + play)
|
||||||
|
├── Game.TicTacToe/ # Integration test: tic-tac-toe game
|
||||||
|
├── Game.TicTacToe.Tests/
|
||||||
|
├── docs/ # Architecture, API surface, implementation plan
|
||||||
|
└── nupkgs/ # Built NuGet packages
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Architecture
|
||||||
|
|
||||||
|
Read `docs/architecture.md` for the full rationale behind each design decision.
|
||||||
|
Key ADRs to keep in mind when changing the library:
|
||||||
|
|
||||||
|
| ADR | Decision | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| 001 | Sparse sets over archetypes | Cheap add/remove for reactivity |
|
||||||
|
| 002 | 32-bit Entity (24 ID + 8 version) | Fits in register, enough headroom |
|
||||||
|
| 003 | Explicit query iteration | Visible cost model, no code-gen needed |
|
||||||
|
| 004 | Registration order for systems | Simplest model that works at this scale |
|
||||||
|
| 005 | Auto-tracking in batching scopes | Eliminates manual `MarkModified` in systems |
|
||||||
|
| 006 | Deferred mutation batching | Prevents mid-system reentrancy, safe iteration |
|
||||||
|
| 007 | R3 for reactivity | Zero-allocation, UI lifecycle-friendly |
|
||||||
|
| 008 | Commands as serializable structs in a queue | Not ECS state |
|
||||||
|
| 009 | Singleton per component type | Each singleton gets its own entity |
|
||||||
|
| 010 | Single-threaded by default | Simpler, no locks needed |
|
||||||
|
| 011 | .NET 8 target | LTS through Nov 2026 |
|
||||||
|
| 012 | Public types required for MessagePack | Build-time validation via analyzer |
|
||||||
|
|
||||||
|
## Key Types
|
||||||
|
|
||||||
|
### `World` — the public entry point
|
||||||
|
|
||||||
|
Everything routes through `World`. It owns:
|
||||||
|
- `EntityAllocator _allocator` — entity lifecycle (create, destroy, recycle).
|
||||||
|
- `ComponentStore _components` — generic sparse set registry.
|
||||||
|
- `RelationshipIndex _relationships` — reverse lookup for relationships.
|
||||||
|
- `ChangeBuffer _changes` — accumulates and posts changes to R3 subjects.
|
||||||
|
- `CommandQueue _commands` — deferred command execution.
|
||||||
|
|
||||||
|
### `ComponentStore` — sparse set registry
|
||||||
|
|
||||||
|
Maps `Type` → `SparseSet<T>`. Exposes `GetSet(Type)` for internal use.
|
||||||
|
Public API on `World` delegates here with generic type resolution.
|
||||||
|
|
||||||
|
### `SparseSet<T>` — per-type component storage
|
||||||
|
|
||||||
|
Dense/sparse array pair. `dense[]` is packed values, `denseEntities[]` is
|
||||||
|
parallel entity IDs, `sparse[]` maps entity ID → dense index (-1 = absent).
|
||||||
|
Uses swap-remove for O(1) deletion.
|
||||||
|
|
||||||
|
### `WorldQueryExtensions.Select1..Select6<T...>` — ref struct iterators
|
||||||
|
|
||||||
|
Zero-allocation `ref struct` enumerators returned by `world.Select<T1..T6>()`.
|
||||||
|
Drive from the smallest sparse set to minimize probes. Support `foreach` via
|
||||||
|
`GetEnumerator()` returning `this`. Expose `Entity`, `Ref1`..`RefN` (mutable,
|
||||||
|
tracked for auto-dirty-marking), and `Val1`..`ValN` (read-only, untracked).
|
||||||
|
Constructor/dispose manage `BeginBatching()`/`EndBatching()` for pending
|
||||||
|
mutation flushing. Singleton entities are excluded via `IsSingletonEntity()`.
|
||||||
|
|
||||||
|
Also provides `FindEntity<T>()` (first match).
|
||||||
|
|
||||||
|
### `Query<T1..T6>` — generic query descriptors
|
||||||
|
|
||||||
|
Structs that encode `With` types as generic parameters. `Without<W>()` fluent
|
||||||
|
method adds exclusion filters without changing the arity. Replaces the old
|
||||||
|
`QueryBuilder` / `QueryDescriptor` / `ForEachAction` pattern.
|
||||||
|
|
||||||
|
### `SystemGroup` — system orchestration
|
||||||
|
|
||||||
|
Manages an ordered list of `ISystem`. `RunAll()`:
|
||||||
|
1. Drain commands (pre-tick).
|
||||||
|
2. `BeginBatching()` — outer batching scope for the tick.
|
||||||
|
3. For each system: run via `Run` extension (nested batch) → drain commands →
|
||||||
|
flush pending mutations → post changes.
|
||||||
|
4. `EndBatching()` — auto-marks + flushes remaining.
|
||||||
|
5. Drain commands + post changes (post-tick).
|
||||||
|
|
||||||
|
### `WorldSerializer` — save/load
|
||||||
|
|
||||||
|
Uses the source-generated `ComponentRegistry` to discover component types
|
||||||
|
without reflection. Serializes each entity's components to MessagePack blobs.
|
||||||
|
On load, fixates `IRelationship.Source` to match the owning entity.
|
||||||
|
|
||||||
|
## Source Generator (`OECS.SourceGen`)
|
||||||
|
|
||||||
|
A Roslyn incremental source generator that:
|
||||||
|
1. Scans for all invocations of World methods with generic type arguments
|
||||||
|
(e.g., `AddComponent<Card>`, `SetSingleton<GameState>`).
|
||||||
|
2. Generates a `ComponentRegistry` class with `ComponentDescriptor[]` listing
|
||||||
|
every discovered type.
|
||||||
|
3. Each descriptor includes the type name, `Type` reference, and static lambdas
|
||||||
|
for serialization/deserialization via MessagePack.
|
||||||
|
|
||||||
|
The generator must be rebuilt before `OECS.csproj` packs into a NuGet package
|
||||||
|
(see the `BuildSourceGenerator` target in `OECS.csproj`).
|
||||||
|
|
||||||
|
## API Surface Contract
|
||||||
|
|
||||||
|
The public API surface documented in `docs/api-surface.md` is the contract.
|
||||||
|
Any change to a public type signature must update the doc. Check for drift
|
||||||
|
regularly — the doc should match the code exactly, not the other way around.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
| Package | Version | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `MessagePack` | 3.1.7 | Binary serialization for components, snapshots |
|
||||||
|
| `MessagePackAnalyzer` | 3.1.7 | Compile-time validation of `[MessagePackObject]` types |
|
||||||
|
| `R3` | 1.2.9 | Reactive observables for change subscriptions |
|
||||||
|
| `Microsoft.CodeAnalysis.CSharp` | via SourceGen | Roslyn incremental generator API |
|
||||||
|
|
||||||
|
## Building & Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build solution
|
||||||
|
dotnet build OECS.sln
|
||||||
|
|
||||||
|
# Run OECS core tests
|
||||||
|
dotnet test OECS.Tests/OECS.Tests.csproj
|
||||||
|
|
||||||
|
# Run blackjack integration tests
|
||||||
|
dotnet test Game.Blackjack.Tests/Game.Blackjack.Tests.csproj
|
||||||
|
|
||||||
|
# Pack NuGet
|
||||||
|
dotnet pack OECS/OECS.csproj -c Release -o nupkgs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding a New Feature
|
||||||
|
|
||||||
|
1. Check ADRs in `docs/architecture.md` — does the change fit the existing
|
||||||
|
decisions? If it contradicts one, write a new ADR or revise the existing one.
|
||||||
|
2. Implement the feature in `OECS/`.
|
||||||
|
3. If it touches public API, update `docs/api-surface.md`.
|
||||||
|
4. Add tests in `OECS.Tests/`.
|
||||||
|
5. Update `docs/implementation-plan.md` if the phase descriptions need adjusting.
|
||||||
|
6. Verify the blackjack game still works (`dotnet test Game.Blackjack.Tests`).
|
||||||
|
|
||||||
|
## Changing the Source Generator
|
||||||
|
|
||||||
|
1. Modify `OECS.SourceGen/`.
|
||||||
|
2. Build it explicitly: `dotnet build OECS.SourceGen/OECS.SourceGen.csproj`.
|
||||||
|
3. The DLL at `OECS.SourceGen/bin/Debug/netstandard2.0/OECS.SourceGen.dll` is
|
||||||
|
referenced as an analyzer by downstream projects.
|
||||||
|
4. Test with a game project to verify component registry is generated correctly.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Component and command types
|
||||||
|
|
||||||
|
Game-level components and commands should be `public record struct` types with
|
||||||
|
explicit fields/properties — **not** positional syntax. Positional record structs
|
||||||
|
produce `init`-only properties that can't be mutated through `ref T`, which
|
||||||
|
breaks the core OECS pattern of in-place component mutation.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ✅ Correct: explicit fields, ref-mutable
|
||||||
|
[MessagePackObject]
|
||||||
|
public record struct Card
|
||||||
|
{
|
||||||
|
[Key(0)] public Suit Suit;
|
||||||
|
[Key(1)] public Rank Rank;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Wrong: positional syntax produces init-only properties
|
||||||
|
[MessagePackObject]
|
||||||
|
public record struct Card([Key(0)] Suit Suit, [Key(1)] Rank Rank);
|
||||||
|
```
|
||||||
|
|
||||||
|
Tag components (no data) can be plain `struct` — `record struct` adds no value
|
||||||
|
when there are no fields to compare.
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- **Using `ValN` when you meant to mutate.** `ValN` returns a `ref readonly` —
|
||||||
|
writes through it still work but won't be tracked for auto-dirty-marking.
|
||||||
|
Use `RefN` for any component you intend to mutate.
|
||||||
|
- **Modifying components during iteration.** Pending mutations are flushed at
|
||||||
|
the end of the batching scope. Adding/removing components mid-iteration is
|
||||||
|
safe — they're deferred.
|
||||||
|
- **Type not public or missing `[MessagePackObject]`.** Serialization will fail.
|
||||||
|
The `MessagePackAnalyzer` catches most issues at compile time.
|
||||||
|
- **Singletons each have their own entity allocated automatically by
|
||||||
|
`SetSingleton<T>`.** Iterators skip singleton entities automatically.
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
---
|
||||||
|
name: testing-games
|
||||||
|
description: Test OECS-based games via unit tests, snapshots, playtests with AI agents, reactivity logs, and serialization round-trips. Use this when writing or running tests for a game built on OECS.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testing Games with OECS
|
||||||
|
|
||||||
|
Read `docs/testing-games.md` for the testing strategy overview. This skill
|
||||||
|
provides the detailed patterns and conventions.
|
||||||
|
|
||||||
|
## Test Project Setup
|
||||||
|
|
||||||
|
A test project references the game DLL plus xUnit and FluentAssertions:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>Game.YourGame.Tests</RootNamespace>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentAssertions" Version="6.12.1" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Game.YourGame\YourGame.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Three Test Categories
|
||||||
|
|
||||||
|
### 1. Unit / Game Flow Tests
|
||||||
|
|
||||||
|
Test individual game rules in isolation. Each test sets up a fresh `World`,
|
||||||
|
enqueues commands, runs a tick, and asserts state:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void PlaceBet_AdvancesToDealing()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
state.Phase.Should().Be(GamePhase.PlayerTurn);
|
||||||
|
state.Chips.Should().Be(90);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Common test helpers:
|
||||||
|
- `SetupGame(seed?)` — create world, register systems, set initial singletons.
|
||||||
|
- `FindEntity<T>(world)` — find first entity with component T (singletons are automatically excluded by query results).
|
||||||
|
- `CountCardsInHand(world, handEntity)` or `GetHandCards(world, handEntity)`.
|
||||||
|
|
||||||
|
Always keep helpers in the test class (or a shared base) rather than in the
|
||||||
|
game DLL — they are test infrastructure.
|
||||||
|
|
||||||
|
### 2. Snapshot & Log Tests
|
||||||
|
|
||||||
|
Capture world state as text and R3 change logs for manual review:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_AfterDeal()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame(seed: 42);
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var snapshot = SnapshotWorld(world);
|
||||||
|
_output.WriteLine(snapshot); // ITestOutputHelper
|
||||||
|
|
||||||
|
snapshot.Should().Contain("Phase: PlayerTurn");
|
||||||
|
snapshot.Should().Contain("Player hand:");
|
||||||
|
snapshot.Should().Contain("Dealer hand:");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`SnapshotWorld(world)` should produce a human-readable text block containing
|
||||||
|
all singletons, entity counts, hand contents, and any other debug-relevant state.
|
||||||
|
|
||||||
|
Reactivity log test pattern:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var log = new List<string>();
|
||||||
|
world.ObserveComponentChanges<Card>().Subscribe(change =>
|
||||||
|
log.Add($"[card] {change}"));
|
||||||
|
world.ObserveComponentChanges<GameState>().Subscribe(change =>
|
||||||
|
log.Add($"[gamestate] {change}"));
|
||||||
|
|
||||||
|
// ... run game ...
|
||||||
|
|
||||||
|
log.Should().Contain(l => l.Contains("EntityAdded"));
|
||||||
|
log.Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState"));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Play Tests
|
||||||
|
|
||||||
|
AI-driven multi-round playtests. See `docs/testing-games.md` for the full
|
||||||
|
strategy. Key patterns:
|
||||||
|
|
||||||
|
**Agents** implement a simple interface:
|
||||||
|
```csharp
|
||||||
|
private interface IYourGameAgent
|
||||||
|
{
|
||||||
|
Decision Decide(World world);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Built-in agent types:
|
||||||
|
- **Greedy/Basic Strategy:** score actions, pick the highest. For blackjack:
|
||||||
|
hit if total < 17, stand otherwise.
|
||||||
|
- **Random:** evenly choose moves randomly (but never do illegal moves).
|
||||||
|
- **Weighted Pool:** pick from a pool of agents by weight each round.
|
||||||
|
|
||||||
|
**Round runner** loops until a terminal condition:
|
||||||
|
```csharp
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int chips = world.ReadSingleton<GameState>().Chips;
|
||||||
|
if (chips < BetAmount) { /* busted */ break; }
|
||||||
|
if (chips >= StartingChips * 2) { /* doubled */ break; }
|
||||||
|
if (totalRounds >= 200) { /* safety cap */ break; }
|
||||||
|
|
||||||
|
// Place bet, deal, agent decides hit/stand, resolve, new round.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Play logs** are saved as `.playlog` files to `AppContext.BaseDirectory/playlogs/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Blackjack: Basic Strategy (seed=42, agent=BasicStrategy) — BUSTED after 38 rounds | W:12 L:22 P:4
|
||||||
|
=================================================================================================
|
||||||
|
|
||||||
|
--- Round Summaries ---
|
||||||
|
Round 1: PlayerBust | Bet=10 | Chips: 100→90 (-10) | Hits: 1
|
||||||
|
...
|
||||||
|
|
||||||
|
--- Decisions ---
|
||||||
|
1. R1 Hand=12, Decision=Hit
|
||||||
|
...
|
||||||
|
|
||||||
|
--- Reactivity ---
|
||||||
|
ComponentModified GameState = ...
|
||||||
|
...
|
||||||
|
|
||||||
|
--- Final State ---
|
||||||
|
Phase: Betting
|
||||||
|
Chips: 0, Bet: 10
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Serialization Round-Trip
|
||||||
|
|
||||||
|
Every game must have a serialization test:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void Serialization_RoundTrips()
|
||||||
|
{
|
||||||
|
var (world, group) = SetupGame(seed: 42);
|
||||||
|
// ... run some game state ...
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
WorldSerializer.Save(world, stream);
|
||||||
|
stream.Position = 0;
|
||||||
|
|
||||||
|
var world2 = new World();
|
||||||
|
WorldSerializer.Load(world2, stream);
|
||||||
|
|
||||||
|
// Assert key state survived:
|
||||||
|
var state = world2.ReadSingleton<GameState>();
|
||||||
|
state.Chips.Should().Be(expected);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test "E:/projects/oecs-sharp/Game.YourGame.Tests/Game.YourGame.Tests.csproj"
|
||||||
|
```
|
||||||
|
|
||||||
|
To run only playtests:
|
||||||
|
```bash
|
||||||
|
dotnet test ... --filter "FullyQualifiedName~PlayTests"
|
||||||
|
```
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
---
|
||||||
|
name: writing-games
|
||||||
|
description: Create game logic using the OECS entity component system (C#). Use this when building a new game or game feature with OECS — defining components, systems, commands, relationships, and singletons.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Writing Games with OECS
|
||||||
|
|
||||||
|
OECS is a single-threaded, observable-first ECS for C#. It targets `net8.0` (C# 12)
|
||||||
|
and depends on `MessagePack` (serialization) and `R3` (reactivity).
|
||||||
|
|
||||||
|
Before writing any code, read `docs/api-surface.md` for the full type reference
|
||||||
|
and `docs/architecture.md` for the design rationale behind the key decisions.
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
A game is a class library referencing `OECS`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Library</OutputType>
|
||||||
|
<RootNamespace>Game.YourGameName</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\OECS\OECS.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
```
|
||||||
|
|
||||||
|
The game DLL must also reference `OECS.SourceGen` as an analyzer so the
|
||||||
|
component registry is generated for serialization. See `Blackjack.csproj` for
|
||||||
|
the exact MSBuild incantation.
|
||||||
|
|
||||||
|
## Defining Components
|
||||||
|
|
||||||
|
Components are `public record struct` types annotated with `[MessagePackObject]`
|
||||||
|
and `[Key]` attributes. Prefer `record struct` by default — it gives you value
|
||||||
|
equality and a generated `ToString()` for free.
|
||||||
|
|
||||||
|
Use **explicit properties or fields** — not positional syntax. OECS mutates
|
||||||
|
components in-place via `ref T`, which requires settable fields/properties.
|
||||||
|
Positional record structs produce `init`-only properties that can't be mutated
|
||||||
|
through a `ref`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[MessagePackObject]
|
||||||
|
public record struct Card
|
||||||
|
{
|
||||||
|
[Key(0)] public Suit Suit;
|
||||||
|
[Key(1)] public Rank Rank;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Types must be `public` — MessagePack requires public accessibility.
|
||||||
|
- Use sequential integer keys starting from 0.
|
||||||
|
- Tag components (no data) are just empty structs:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[MessagePackObject]
|
||||||
|
public struct PlayerHand { }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
Relationships are components that implement `IRelationship`. They model a
|
||||||
|
directed edge between a source entity and a target entity.
|
||||||
|
|
||||||
|
You can either use the generic `Relationship<TSelf, TTarget>` base struct or
|
||||||
|
implement `IRelationship` directly:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Using the base struct:
|
||||||
|
world.AddComponent(child, new Relationship<ChildOf, Parent>
|
||||||
|
{
|
||||||
|
Source = child,
|
||||||
|
Target = parent
|
||||||
|
});
|
||||||
|
|
||||||
|
// Direct implementation (preferred for domain-specific names):
|
||||||
|
[MessagePackObject]
|
||||||
|
public record struct Holds : IRelationship
|
||||||
|
{
|
||||||
|
[Key(0)] public Entity Source { get; set; }
|
||||||
|
[Key(1)] public Entity Target { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reverse lookup is automatic. The `World` maintains a reverse index so you can
|
||||||
|
query all sources pointing to a target:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var cards = world.GetSources<Holds>(handEntity);
|
||||||
|
```
|
||||||
|
|
||||||
|
When an entity is destroyed, all relationships it participates in (as source or
|
||||||
|
target) are cleaned up automatically.
|
||||||
|
|
||||||
|
## Defining Systems
|
||||||
|
|
||||||
|
Systems implement `ISystem` (or `ITickedSystem` if they need delta time).
|
||||||
|
Implement `RunImpl` with the system logic:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class DealSystem : ISystem
|
||||||
|
{
|
||||||
|
public void RunImpl(World world)
|
||||||
|
{
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
if (state.Phase != GamePhase.Dealing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Do work...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `ISystem` interface has no `Query` property. Systems read singletons,
|
||||||
|
build queries, and iterate on their own — this keeps the interface minimal
|
||||||
|
and gives systems full flexibility.
|
||||||
|
|
||||||
|
### Running Systems
|
||||||
|
|
||||||
|
Use the `Run` extension method to execute a system with automatic batching:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var system = new MySystem();
|
||||||
|
system.Run(world); // wraps RunImpl in BeginBatching/EndBatching
|
||||||
|
```
|
||||||
|
|
||||||
|
`SystemGroup` handles this automatically — you just register systems and call
|
||||||
|
`RunLogical()` or `RunTimed()`.
|
||||||
|
|
||||||
|
### Iteration
|
||||||
|
|
||||||
|
Use `world.Select<T1..T6>()` with `foreach` for zero-allocation iteration with
|
||||||
|
`ref` access to components. Use `RefN` for components you intend to mutate
|
||||||
|
(tracked for auto-dirty-marking) and `ValN` for read-only access (untracked):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
foreach (var it in world.Select<Position, Velocity>())
|
||||||
|
{
|
||||||
|
it.Ref1.X += it.Val2.X * dt; // Position tracked, Velocity not
|
||||||
|
it.Ref1.Y += it.Val2.Y * dt;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For queries with exclusion filters, create a `Query<T>` with `.Without<W>()`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var query = new Query<Cell>().Without<Mark>();
|
||||||
|
foreach (var it in world.Select(query))
|
||||||
|
{
|
||||||
|
if (it.Val1.Row == row && it.Val1.Col == col) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To find a single entity, use `FindEntity<T>()`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var handEntity = world.FindEntity<PlayerHand>();
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Select` ref struct enumerators expose:
|
||||||
|
- `it.Entity` — the current entity handle.
|
||||||
|
- `it.Ref1`..`it.RefN` — `ref` references to components (tracked for auto-dirty).
|
||||||
|
- `it.Val1`..`it.ValN` — `ref readonly` references to components (untracked).
|
||||||
|
|
||||||
|
Singleton entities are automatically excluded from query results.
|
||||||
|
|
||||||
|
### System Registration
|
||||||
|
|
||||||
|
Systems run in registration order via `SystemGroup`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var world = new World();
|
||||||
|
var group = new SystemGroup(world);
|
||||||
|
group.Add(new DeckSetupSystem());
|
||||||
|
group.Add(new DealSystem());
|
||||||
|
group.Add(new PlayerBustCheckSystem());
|
||||||
|
group.Add(new DealerSystem());
|
||||||
|
```
|
||||||
|
|
||||||
|
`SystemGroup` automatically drains commands, flushes pending mutations, and
|
||||||
|
posts changes after each system and after the full tick.
|
||||||
|
|
||||||
|
## Defining Commands
|
||||||
|
|
||||||
|
Commands are `public record struct` types annotated with `[MessagePackObject]`
|
||||||
|
and implementing `ICommand`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[MessagePackObject]
|
||||||
|
public record struct PlaceBetCommand : ICommand
|
||||||
|
{
|
||||||
|
[Key(0)] public int Amount;
|
||||||
|
|
||||||
|
public void Execute(World world)
|
||||||
|
{
|
||||||
|
ref var state = ref world.GetSingleton<GameState>();
|
||||||
|
if (state.Phase != GamePhase.Betting) return;
|
||||||
|
state.CurrentBet = Amount;
|
||||||
|
state.Chips -= Amount;
|
||||||
|
state.Phase = GamePhase.Dealing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enqueue commands via `world.Commands.Enqueue(...)`. They execute deferred
|
||||||
|
when the queue is drained (automatically by `SystemGroup`). Commands run
|
||||||
|
inside a batching scope, so `GetSingleton` and `GetComponent` calls are
|
||||||
|
auto-tracked for modification.
|
||||||
|
|
||||||
|
## Singletons
|
||||||
|
|
||||||
|
Each singleton component type gets its own dedicated entity, allocated
|
||||||
|
automatically by `SetSingleton<T>`. Use `SetSingleton<T>`, `GetSingleton<T>`
|
||||||
|
(ref), and `ReadSingleton<T>` (copy):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Setup:
|
||||||
|
world.SetSingleton(new GameState { Phase = GamePhase.Betting, Chips = 100 });
|
||||||
|
|
||||||
|
// Read-only inspection:
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
|
||||||
|
// Mutation (auto-tracked during batching scopes):
|
||||||
|
ref var mutable = ref world.GetSingleton<GameState>();
|
||||||
|
mutable.Phase = GamePhase.RoundOver;
|
||||||
|
```
|
||||||
|
|
||||||
|
`GetSingleton` returns a `ref`. During system execution, command drains, and
|
||||||
|
`foreach` iterations, mutations via `GetSingleton` are auto-marked for change
|
||||||
|
tracking. Outside a batching scope, call `MarkModified` after mutating.
|
||||||
|
`ReadSingleton` returns a copy and never auto-marks.
|
||||||
|
|
||||||
|
## Change Tracking
|
||||||
|
|
||||||
|
- Structural changes (entity create/destroy, component add/remove) are always
|
||||||
|
auto-marked.
|
||||||
|
- Value mutations via `GetComponent<T>`, `GetSingleton<T>`, and `RefN` iterator
|
||||||
|
properties are auto-tracked during batching scopes (system runs, command
|
||||||
|
drains, `foreach` iterations).
|
||||||
|
- Outside batching scopes, call `world.MarkModified<T>(entity)` explicitly.
|
||||||
|
- Changes are posted after each system runs (automatic via `SystemGroup`).
|
||||||
|
|
||||||
|
## Serialization
|
||||||
|
|
||||||
|
`WorldSerializer.Save/Load` uses the source-generated `ComponentRegistry`.
|
||||||
|
All component types used with `World` generic methods are automatically
|
||||||
|
discovered. Serialization round-trips must be tested — see `testing-games` skill.
|
||||||
|
|
||||||
|
## Interrupts
|
||||||
|
|
||||||
|
Interrupts pause system execution until an external response arrives. A system
|
||||||
|
issues an interrupt, the next tick is skipped, and a handler command resolves it.
|
||||||
|
|
||||||
|
### Issuing an Interrupt
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[MessagePackObject]
|
||||||
|
public record struct ConfirmInterrupt : IInterrupt
|
||||||
|
{
|
||||||
|
[Key(0)] public string Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In a system:
|
||||||
|
world.Interrupt(new ConfirmInterrupt { Message = "Are you sure?" });
|
||||||
|
```
|
||||||
|
|
||||||
|
The current tick completes normally. The **next** tick is blocked — `SystemGroup`
|
||||||
|
skips all systems but still drains commands and posts changes.
|
||||||
|
|
||||||
|
### Handling an Interrupt
|
||||||
|
|
||||||
|
Handler commands implement `IInterruptHandlerCommand<T>`. The default `Execute`
|
||||||
|
calls `TryResolve` with the pending interrupt. Return `true` to resolve it,
|
||||||
|
`false` to leave it pending for another handler:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[MessagePackObject]
|
||||||
|
public struct ConfirmHandler : IInterruptHandlerCommand<ConfirmInterrupt>
|
||||||
|
{
|
||||||
|
[Key(0)] public bool Confirmed;
|
||||||
|
|
||||||
|
public bool TryResolve(ConfirmInterrupt interrupt)
|
||||||
|
{
|
||||||
|
// Do work here. The interrupt is just a signal.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// External code (e.g., UI button) enqueues the handler:
|
||||||
|
world.Commands.Enqueue(new ConfirmHandler { Confirmed = true });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Rules
|
||||||
|
|
||||||
|
- Only one interrupt of a given type may be pending at a time. A second call
|
||||||
|
to `Interrupt<T>` with the same type throws.
|
||||||
|
- Interrupts are transient — they are not serialized and do not survive save/load.
|
||||||
|
- If no `SystemGroup` manages the world, `Interrupt<T>()` is a no-op.
|
||||||
|
- Check pending interrupts with `world.HasInterrupt<T>()`.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>12</LangVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>Game.Blackjack.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.Blackjack\Blackjack.csproj" />
|
||||||
|
<ProjectReference Include="..\OECS.PlayTest\OECS.PlayTest.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Game.Blackjack;
|
||||||
|
using OECS;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Game.Blackjack.Tests;
|
||||||
|
|
||||||
|
public class GameFlowTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void NewGame_StartsInBettingPhase()
|
||||||
|
{
|
||||||
|
var (world, _) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
state.Phase.Should().Be(GamePhase.Betting);
|
||||||
|
state.Chips.Should().Be(100);
|
||||||
|
state.RoundNumber.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlaceBet_AdvancesToDealing()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
state.CurrentBet.Should().Be(10);
|
||||||
|
state.Chips.Should().Be(90);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlaceBet_RejectsInsufficientChips()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
state.Chips.Should().Be(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlaceBet_RejectsZeroOrNegative()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 0 });
|
||||||
|
group.RunLogical();
|
||||||
|
world.ReadSingleton<GameState>().Phase.Should().Be(GamePhase.Betting);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = -5 });
|
||||||
|
group.RunLogical();
|
||||||
|
world.ReadSingleton<GameState>().Phase.Should().Be(GamePhase.Betting);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dealing_CreatesDeckAndHands()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
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) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var playerHand = TestHelpers.FindEntity<PlayerHand>(world);
|
||||||
|
var dealerHand = TestHelpers.FindEntity<DealerHand>(world);
|
||||||
|
|
||||||
|
TestHelpers.CountCardsInHand(world, playerHand).Should().Be(2);
|
||||||
|
TestHelpers.CountCardsInHand(world, dealerHand).Should().Be(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Hit_DrawsOneCard()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var playerHand = TestHelpers.FindEntity<PlayerHand>(world);
|
||||||
|
var before = TestHelpers.CountCardsInHand(world, playerHand);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new HitCommand());
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var after = TestHelpers.CountCardsInHand(world, playerHand);
|
||||||
|
after.Should().Be(before + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Stand_AdvancesToDealerTurn()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new StandCommand());
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
state.Phase.Should().Be(GamePhase.RoundOver);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NewRound_ResetsPhase()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
world.Commands.Enqueue(new StandCommand());
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new NewRoundCommand());
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
state.Phase.Should().Be(GamePhase.Betting);
|
||||||
|
state.Result.Should().Be(RoundResult.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeterministicSeed_ProducesSameDeal()
|
||||||
|
{
|
||||||
|
var (world1, group1) = TestHelpers.SetupGame(seed: 42);
|
||||||
|
world1.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group1.RunLogical();
|
||||||
|
var cards1 = TestHelpers.GetAllCards(world1);
|
||||||
|
|
||||||
|
var (world2, group2) = TestHelpers.SetupGame(seed: 42);
|
||||||
|
world2.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group2.RunLogical();
|
||||||
|
var cards2 = TestHelpers.GetAllCards(world2);
|
||||||
|
|
||||||
|
cards1.Should().Equal(cards2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlayerBust_LosesBet()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame(seed: 12345);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn)
|
||||||
|
{
|
||||||
|
world.Commands.Enqueue(new HitCommand());
|
||||||
|
group.RunLogical();
|
||||||
|
}
|
||||||
|
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
state.Phase.Should().Be(GamePhase.RoundOver);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Serialization_RoundTrips()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame(seed: 42);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
WorldSerializer.Save(world, stream);
|
||||||
|
stream.Position = 0;
|
||||||
|
|
||||||
|
var world2 = new World();
|
||||||
|
WorldSerializer.Load(world2, stream);
|
||||||
|
|
||||||
|
var state = world2.ReadSingleton<GameState>();
|
||||||
|
state.Chips.Should().Be(90);
|
||||||
|
state.CurrentBet.Should().Be(10);
|
||||||
|
state.RoundNumber.Should().Be(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Game.Blackjack;
|
||||||
|
using OECS;
|
||||||
|
using OECS.PlayTest;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Game.Blackjack.Tests;
|
||||||
|
|
||||||
|
public class PlayTests
|
||||||
|
{
|
||||||
|
private const int StartingChips = 100;
|
||||||
|
private const int BetAmount = 10;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Play_BasicStrategy()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame(seed: 42);
|
||||||
|
var agent = new BasicStrategyAgent();
|
||||||
|
|
||||||
|
var log = RunUntilDone(world, group, agent,
|
||||||
|
$"Blackjack: Basic Strategy (seed=42, agent={agent})");
|
||||||
|
|
||||||
|
var path = log.SaveTo("blackjack_basic_strategy.playlog");
|
||||||
|
ReadAndVerify(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Play_RandomAgent()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame(seed: 123);
|
||||||
|
var agent = new RandomBlackjackAgent();
|
||||||
|
|
||||||
|
var log = RunUntilDone(world, group, agent,
|
||||||
|
$"Blackjack: Random Agent (seed=123, agent={agent})");
|
||||||
|
|
||||||
|
var path = log.SaveTo("blackjack_random_agent.playlog");
|
||||||
|
ReadAndVerify(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Play_WeightedPool()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame(seed: 77);
|
||||||
|
|
||||||
|
var pool = new WeightedAgentPool<BlackjackDecision>();
|
||||||
|
pool.Add(new BasicStrategyAgent(), 7);
|
||||||
|
pool.Add(new RandomBlackjackAgent(), 3);
|
||||||
|
|
||||||
|
var agent = pool.Pick();
|
||||||
|
var log = RunUntilDone(world, group, agent,
|
||||||
|
$"Blackjack: Weighted Pool (seed=77, agent={agent})");
|
||||||
|
|
||||||
|
var path = log.SaveTo("blackjack_weighted_pool.playlog");
|
||||||
|
ReadAndVerify(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Round Runner ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs rounds until the player runs out of chips (can't afford minimum bet)
|
||||||
|
/// or doubles their starting chips.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (chips < BetAmount)
|
||||||
|
{
|
||||||
|
log.Header += $" — BUSTED after {totalRounds} rounds";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (chips >= StartingChips * 2)
|
||||||
|
{
|
||||||
|
log.Header += $" — DOUBLED after {totalRounds} rounds";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (totalRounds >= 200)
|
||||||
|
{
|
||||||
|
log.Header += $" — MAX ROUNDS ({totalRounds})";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = BetAmount });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
int roundHits = 0;
|
||||||
|
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);
|
||||||
|
decisions.Add($"R{totalRounds + 1} Hand={total}, Decision={decision}");
|
||||||
|
|
||||||
|
if (decision == BlackjackDecision.Hit)
|
||||||
|
{
|
||||||
|
world.Commands.Enqueue(new HitCommand());
|
||||||
|
roundHits++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
world.Commands.Enqueue(new StandCommand());
|
||||||
|
}
|
||||||
|
|
||||||
|
group.RunLogical();
|
||||||
|
}
|
||||||
|
|
||||||
|
state = world.ReadSingleton<GameState>();
|
||||||
|
int newChips = state.Chips;
|
||||||
|
int delta = newChips - chips;
|
||||||
|
roundSummaries.Add($"Round {totalRounds + 1}: {state.Result} | Bet={BetAmount} | Chips: {chips}→{newChips} ({delta:+0;-#}) | Hits: {roundHits}");
|
||||||
|
|
||||||
|
switch (state.Result)
|
||||||
|
{
|
||||||
|
case RoundResult.PlayerWin:
|
||||||
|
case RoundResult.DealerBust:
|
||||||
|
wins++;
|
||||||
|
break;
|
||||||
|
case RoundResult.DealerWin:
|
||||||
|
case RoundResult.PlayerBust:
|
||||||
|
losses++;
|
||||||
|
break;
|
||||||
|
case RoundResult.Push:
|
||||||
|
pushes++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalRounds++;
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new NewRoundCommand());
|
||||||
|
group.RunLogical();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Header += $" | W:{wins} L:{losses} P:{pushes}";
|
||||||
|
|
||||||
|
log.AddSection("Round Summaries", roundSummaries);
|
||||||
|
log.AddSection("Decisions", decisions);
|
||||||
|
log.AddSection("Reactivity", capture.GetLogLines());
|
||||||
|
log.FinalSnapshot = TestHelpers.SnapshotWorld(world);
|
||||||
|
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File I/O ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void ReadAndVerify(string path)
|
||||||
|
{
|
||||||
|
var content = File.ReadAllText(path);
|
||||||
|
|
||||||
|
content.Should().Contain("--- Round Summaries ---");
|
||||||
|
content.Should().Contain("--- Decisions ---");
|
||||||
|
content.Should().Contain("--- Reactivity ---");
|
||||||
|
content.Should().Contain("--- Final State ---");
|
||||||
|
content.Should().Contain("Result:");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agents ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private enum BlackjackDecision { Hit, Stand }
|
||||||
|
|
||||||
|
private sealed class BasicStrategyAgent : IAgent<BlackjackDecision>
|
||||||
|
{
|
||||||
|
public BlackjackDecision Decide(World world)
|
||||||
|
{
|
||||||
|
var total = HandUtil.CalculateHand(world, new PlayerHand());
|
||||||
|
return total < 17 ? BlackjackDecision.Hit : BlackjackDecision.Stand;
|
||||||
|
}
|
||||||
|
public override string ToString() => "BasicStrategy";
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RandomBlackjackAgent : IAgent<BlackjackDecision>
|
||||||
|
{
|
||||||
|
private static readonly Random _rng = new();
|
||||||
|
public BlackjackDecision Decide(World world)
|
||||||
|
{
|
||||||
|
var total = HandUtil.CalculateHand(world, new PlayerHand());
|
||||||
|
if (total >= 21) return BlackjackDecision.Stand;
|
||||||
|
return _rng.Next(2) == 0 ? BlackjackDecision.Hit : BlackjackDecision.Stand;
|
||||||
|
}
|
||||||
|
public override string ToString() => "Random";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Game.Blackjack;
|
||||||
|
using OECS;
|
||||||
|
using OECS.PlayTest;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace Game.Blackjack.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Baseline snapshot and log tests for Blackjack.
|
||||||
|
/// Captures the world state as text and R3 change logs so they can be
|
||||||
|
/// reviewed manually and compared across changes.
|
||||||
|
/// </summary>
|
||||||
|
public class SnapshotTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _output;
|
||||||
|
|
||||||
|
public SnapshotTests(ITestOutputHelper output)
|
||||||
|
{
|
||||||
|
_output = output;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_InitialState()
|
||||||
|
{
|
||||||
|
var (world, _) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
var snapshot = TestHelpers.SnapshotWorld(world);
|
||||||
|
_output.WriteLine(snapshot);
|
||||||
|
|
||||||
|
snapshot.Should().Contain("Phase: Betting");
|
||||||
|
snapshot.Should().Contain("Chips: 100");
|
||||||
|
snapshot.Should().Contain("Round: 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_AfterDeal()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame(seed: 42);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var snapshot = TestHelpers.SnapshotWorld(world);
|
||||||
|
_output.WriteLine(snapshot);
|
||||||
|
|
||||||
|
snapshot.Should().Contain("Phase: PlayerTurn");
|
||||||
|
snapshot.Should().Contain("Chips: 90");
|
||||||
|
snapshot.Should().Contain("Bet: 10");
|
||||||
|
snapshot.Should().Contain("Player hand:");
|
||||||
|
snapshot.Should().Contain("Dealer hand:");
|
||||||
|
snapshot.Should().Contain("Cards in deck:");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_AfterHit()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame(seed: 42);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new HitCommand());
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var snapshot = TestHelpers.SnapshotWorld(world);
|
||||||
|
_output.WriteLine(snapshot);
|
||||||
|
|
||||||
|
snapshot.Should().NotContain("Phase: Dealing");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_AfterStand()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame(seed: 42);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new StandCommand());
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var snapshot = TestHelpers.SnapshotWorld(world);
|
||||||
|
_output.WriteLine(snapshot);
|
||||||
|
|
||||||
|
snapshot.Should().Contain("Phase: RoundOver");
|
||||||
|
snapshot.Should().Contain("Result:");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Log_ReactivityDuringRound()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame(seed: 42);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new StandCommand());
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var logText = string.Join("\n", capture.GetLogLines());
|
||||||
|
_output.WriteLine(logText);
|
||||||
|
|
||||||
|
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) = TestHelpers.SetupGame(seed: 42);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var before = TestHelpers.SnapshotWorld(world);
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
WorldSerializer.Save(world, stream);
|
||||||
|
stream.Position = 0;
|
||||||
|
|
||||||
|
var world2 = new World();
|
||||||
|
WorldSerializer.Load(world2, stream);
|
||||||
|
|
||||||
|
var after = TestHelpers.SnapshotWorld(world2);
|
||||||
|
|
||||||
|
_output.WriteLine("=== Before ===");
|
||||||
|
_output.WriteLine(before);
|
||||||
|
_output.WriteLine("=== After ===");
|
||||||
|
_output.WriteLine(after);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
after.Should().Contain("Card entities: 52");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Library</OutputType>
|
||||||
|
<RootNamespace>Game.Blackjack</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\OECS\OECS.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Source generator: referenced as a built DLL. -->
|
||||||
|
<Target Name="EnsureSourceGenBuilt" BeforeTargets="CoreCompile">
|
||||||
|
<MSBuild Projects="..\OECS.SourceGen\OECS.SourceGen.csproj"
|
||||||
|
Targets="Build"
|
||||||
|
Properties="Configuration=$(Configuration)" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Analyzer Include="..\OECS.SourceGen\bin\$(Configuration)\netstandard2.0\OECS.SourceGen.dll" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using MessagePack;
|
using MessagePack;
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Player hits: draws one card from the deck to the player's hand.
|
/// Player hits: draws one card from the deck to the player's hand.
|
||||||
@@ -11,7 +11,7 @@ public struct HitCommand : ICommand
|
|||||||
{
|
{
|
||||||
public void Execute(World world)
|
public void Execute(World world)
|
||||||
{
|
{
|
||||||
ref var state = ref world.GetSingleton<GameState>();
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
|
||||||
if (state.Phase != GamePhase.PlayerTurn)
|
if (state.Phase != GamePhase.PlayerTurn)
|
||||||
return;
|
return;
|
||||||
@@ -25,9 +25,8 @@ public struct HitCommand : ICommand
|
|||||||
internal static void DrawCard<THand>(World world)
|
internal static void DrawCard<THand>(World world)
|
||||||
where THand : struct
|
where THand : struct
|
||||||
{
|
{
|
||||||
var singletonEntity = World.SingletonEntity;
|
var deckEntity = world.FindEntity<Deck>();
|
||||||
var deckEntity = FindEntity<Deck>(world, singletonEntity);
|
var handEntity = world.FindEntity<THand>();
|
||||||
var handEntity = FindEntity<THand>(world, singletonEntity);
|
|
||||||
|
|
||||||
if (deckEntity == Entity.Null || handEntity == Entity.Null)
|
if (deckEntity == Entity.Null || handEntity == Entity.Null)
|
||||||
return;
|
return;
|
||||||
@@ -41,18 +40,15 @@ public struct HitCommand : ICommand
|
|||||||
|
|
||||||
// Remove from deck, add to hand.
|
// Remove from deck, add to hand.
|
||||||
world.RemoveComponent<InDeck>(cardEntity);
|
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
|
where T : struct
|
||||||
{
|
{
|
||||||
using var iter = world.Select<T>();
|
return world.FindEntity<T>();
|
||||||
while (iter.MoveNext())
|
|
||||||
{
|
|
||||||
if (iter.CurrentEntity != singletonEntity)
|
|
||||||
return iter.CurrentEntity;
|
|
||||||
}
|
|
||||||
return Entity.Null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+6
-9
@@ -1,7 +1,7 @@
|
|||||||
using MessagePack;
|
using MessagePack;
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Starts a new round after the previous one ended.
|
/// Starts a new round after the previous one ended.
|
||||||
@@ -17,37 +17,34 @@ public struct NewRoundCommand : ICommand
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
// Clear hands from previous round.
|
// Clear hands from previous round.
|
||||||
var singletonEntity = World.SingletonEntity;
|
|
||||||
var handEntities = new List<Entity>();
|
var handEntities = new List<Entity>();
|
||||||
using (var iter = world.Select<PlayerHand>())
|
using (var iter = world.Select<PlayerHand>())
|
||||||
{
|
{
|
||||||
while (iter.MoveNext())
|
while (iter.MoveNext())
|
||||||
{
|
{
|
||||||
if (iter.CurrentEntity != singletonEntity)
|
handEntities.Add(iter.Entity);
|
||||||
handEntities.Add(iter.CurrentEntity);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
using (var iter = world.Select<DealerHand>())
|
using (var iter = world.Select<DealerHand>())
|
||||||
{
|
{
|
||||||
while (iter.MoveNext())
|
while (iter.MoveNext())
|
||||||
{
|
{
|
||||||
if (iter.CurrentEntity != singletonEntity)
|
handEntities.Add(iter.Entity);
|
||||||
handEntities.Add(iter.CurrentEntity);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foreach (var hand in handEntities)
|
foreach (var hand in handEntities)
|
||||||
{
|
{
|
||||||
var cards = world.GetSources<Holds>(hand);
|
var cards = world.GetSources<Holds>(hand);
|
||||||
var deckEntity = HitCommand.FindEntity<Deck>(world, singletonEntity);
|
var deckEntity = world.FindEntity<Deck>();
|
||||||
foreach (var card in cards)
|
foreach (var card in cards)
|
||||||
{
|
{
|
||||||
world.RemoveComponent<Holds>(card);
|
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.Phase = GamePhase.Betting;
|
||||||
state.Result = RoundResult.None;
|
state.Result = RoundResult.None;
|
||||||
world.MarkModified<GameState>(World.SingletonEntity);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-2
@@ -1,7 +1,7 @@
|
|||||||
using MessagePack;
|
using MessagePack;
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Places a bet and advances the game to the dealing phase.
|
/// Places a bet and advances the game to the dealing phase.
|
||||||
@@ -24,6 +24,5 @@ public struct PlaceBetCommand : ICommand
|
|||||||
state.CurrentBet = Amount;
|
state.CurrentBet = Amount;
|
||||||
state.Chips -= Amount;
|
state.Chips -= Amount;
|
||||||
state.Phase = GamePhase.Dealing;
|
state.Phase = GamePhase.Dealing;
|
||||||
world.MarkModified<GameState>(World.SingletonEntity);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-2
@@ -1,7 +1,7 @@
|
|||||||
using MessagePack;
|
using MessagePack;
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Player stands: advance to the dealer's turn.
|
/// Player stands: advance to the dealer's turn.
|
||||||
@@ -17,6 +17,5 @@ public struct StandCommand : ICommand
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
state.Phase = GamePhase.DealerTurn;
|
state.Phase = GamePhase.DealerTurn;
|
||||||
world.MarkModified<GameState>(World.SingletonEntity);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
using MessagePack;
|
using MessagePack;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A playing card with a suit and rank.
|
/// A playing card with a suit and rank.
|
||||||
/// One entity per card.
|
/// One entity per card.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct Card
|
public record struct Card
|
||||||
{
|
{
|
||||||
[Key(0)] public Suit Suit;
|
[Key(0)] public Suit Suit;
|
||||||
[Key(1)] public Rank Rank;
|
[Key(1)] public Rank Rank;
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
using MessagePack;
|
using MessagePack;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tag component marking the dealer's hand entity.
|
/// Tag component marking the dealer's hand entity.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using MessagePack;
|
using MessagePack;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tag component marking the deck entity.
|
/// Tag component marking the deck entity.
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
using MessagePack;
|
using MessagePack;
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Relationship from a hand entity to a card entity,
|
/// Relationship from a hand entity to a card entity,
|
||||||
/// representing that the hand holds this card.
|
/// representing that the hand holds this card.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct Holds : IRelationship
|
public record struct Holds : IRelationship
|
||||||
{
|
{
|
||||||
[Key(0)] public Entity Source { get; set; }
|
[Key(0)] public Entity Target { get; set; }
|
||||||
[Key(1)] public Entity Target { get; set; }
|
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
using MessagePack;
|
using MessagePack;
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Relationship from a card entity to the deck entity,
|
/// Relationship from a card entity to the deck entity,
|
||||||
/// representing that the card is in the deck.
|
/// representing that the card is in the deck.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct InDeck : IRelationship
|
public record struct InDeck : IRelationship
|
||||||
{
|
{
|
||||||
[Key(0)] public Entity Source { get; set; }
|
[Key(0)] public Entity Target { get; set; }
|
||||||
[Key(1)] public Entity Target { get; set; }
|
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
using MessagePack;
|
using MessagePack;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tag component marking the player's hand entity.
|
/// Tag component marking the player's hand entity.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
public enum Rank : byte
|
public enum Rank : byte
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
public enum Suit : byte
|
public enum Suit : byte
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Mulberry32 PRNG — a fast, high-quality 32-bit random number generator.
|
/// Mulberry32 PRNG — a fast, high-quality 32-bit random number generator.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
public enum GamePhase : byte
|
public enum GamePhase : byte
|
||||||
{
|
{
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
using MessagePack;
|
using MessagePack;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Global game state stored on the singleton entity.
|
/// Global game state stored on the singleton entity.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct GameState
|
public record struct GameState
|
||||||
{
|
{
|
||||||
[Key(0)] public GamePhase Phase;
|
[Key(0)] public GamePhase Phase;
|
||||||
[Key(1)] public RoundResult Result;
|
[Key(1)] public RoundResult Result;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
public enum RoundResult : byte
|
public enum RoundResult : byte
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deals initial two cards to player and dealer when entering the dealing phase.
|
/// Deals initial two cards to player and dealer when entering the dealing phase.
|
||||||
@@ -8,7 +8,7 @@ namespace Blackjack;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class DealSystem : ISystem
|
public class DealSystem : ISystem
|
||||||
{
|
{
|
||||||
public void Run(World world)
|
public void RunImpl(World world)
|
||||||
{
|
{
|
||||||
var state = world.ReadSingleton<GameState>();
|
var state = world.ReadSingleton<GameState>();
|
||||||
if (state.Phase != GamePhase.Dealing)
|
if (state.Phase != GamePhase.Dealing)
|
||||||
@@ -23,7 +23,6 @@ public class DealSystem : ISystem
|
|||||||
HitCommand.DrawCard<DealerHand>(world);
|
HitCommand.DrawCard<DealerHand>(world);
|
||||||
|
|
||||||
mutableState.Phase = GamePhase.PlayerTurn;
|
mutableState.Phase = GamePhase.PlayerTurn;
|
||||||
world.MarkModified<GameState>(World.SingletonEntity);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Dealer draws cards until reaching 17 or higher,
|
/// Dealer draws cards until reaching 17 or higher,
|
||||||
@@ -8,7 +8,7 @@ namespace Blackjack;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class DealerSystem : ISystem
|
public class DealerSystem : ISystem
|
||||||
{
|
{
|
||||||
public void Run(World world)
|
public void RunImpl(World world)
|
||||||
{
|
{
|
||||||
var state = world.ReadSingleton<GameState>();
|
var state = world.ReadSingleton<GameState>();
|
||||||
if (state.Phase != GamePhase.DealerTurn)
|
if (state.Phase != GamePhase.DealerTurn)
|
||||||
@@ -18,10 +18,12 @@ public class DealerSystem : ISystem
|
|||||||
|
|
||||||
// Dealer must hit on 16 and below, stand on 17+.
|
// Dealer must hit on 16 and below, stand on 17+.
|
||||||
int dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
|
int dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
|
||||||
while (dealerTotal < 17)
|
int safety = 0;
|
||||||
|
while (dealerTotal < 17 && safety < 52)
|
||||||
{
|
{
|
||||||
HitCommand.DrawCard<DealerHand>(world);
|
HitCommand.DrawCard<DealerHand>(world);
|
||||||
dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
|
dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
|
||||||
|
safety++;
|
||||||
}
|
}
|
||||||
|
|
||||||
int playerTotal = HandUtil.CalculateHand(world, PlayerHandTag.Instance);
|
int playerTotal = HandUtil.CalculateHand(world, PlayerHandTag.Instance);
|
||||||
@@ -49,6 +51,5 @@ public class DealerSystem : ISystem
|
|||||||
mutableState.Chips += mutableState.CurrentBet;
|
mutableState.Chips += mutableState.CurrentBet;
|
||||||
}
|
}
|
||||||
|
|
||||||
world.MarkModified<GameState>(World.SingletonEntity);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+7
-25
@@ -1,6 +1,6 @@
|
|||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates the deck (52 cards + deck/hand entities) and shuffles
|
/// Creates the deck (52 cards + deck/hand entities) and shuffles
|
||||||
@@ -9,18 +9,17 @@ namespace Blackjack;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class DeckSetupSystem : ISystem
|
public class DeckSetupSystem : ISystem
|
||||||
{
|
{
|
||||||
public void Run(World world)
|
public void RunImpl(World world)
|
||||||
{
|
{
|
||||||
var state = world.ReadSingleton<GameState>();
|
var state = world.ReadSingleton<GameState>();
|
||||||
if (state.Phase != GamePhase.Dealing)
|
if (state.Phase != GamePhase.Dealing)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Only create deck entities if they don't exist yet.
|
// Only create deck entities if they don't exist yet.
|
||||||
var singletonEntity = World.SingletonEntity;
|
|
||||||
bool hasDeck = false;
|
bool hasDeck = false;
|
||||||
using (var iter = world.Select<Deck>())
|
using (var iter = world.Select<Deck>())
|
||||||
{
|
{
|
||||||
hasDeck = iter.MoveNext() && iter.CurrentEntity != singletonEntity;
|
hasDeck = iter.MoveNext();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasDeck)
|
if (!hasDeck)
|
||||||
@@ -36,7 +35,7 @@ public class DeckSetupSystem : ISystem
|
|||||||
{
|
{
|
||||||
var cardEntity = world.CreateEntity();
|
var cardEntity = world.CreateEntity();
|
||||||
world.AddComponent(cardEntity, new Card { Suit = suit, Rank = rank });
|
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.
|
// Shuffle the deck using mulberry32 with the current seed.
|
||||||
ref var mutableState = ref world.GetSingleton<GameState>();
|
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();
|
var cards = world.GetSources<InDeck>(deckEntity2).ToArray();
|
||||||
Shuffle(world, deckEntity2, cards, ref mutableState.Seed);
|
Shuffle(world, deckEntity2, cards, ref mutableState.Seed);
|
||||||
}
|
}
|
||||||
@@ -61,27 +60,10 @@ public class DeckSetupSystem : ISystem
|
|||||||
for (int i = cardEntities.Length - 1; i > 0; i--)
|
for (int i = cardEntities.Length - 1; i > 0; i--)
|
||||||
{
|
{
|
||||||
int j = Mulberry32.NextInt(ref seed, 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]);
|
(cardEntities[i], cardEntities[j]) = (cardEntities[j], cardEntities[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now remove all InDeck and re-add in shuffled order so GetSources
|
// Reorder the source set in-place — no component add/remove needed.
|
||||||
// returns them in shuffled order.
|
world.ReorderSources<InDeck>(deckEntity, cardEntities);
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Helper to calculate the blackjack value of a hand.
|
/// Helper to calculate the blackjack value of a hand.
|
||||||
@@ -22,18 +22,7 @@ public static class HandUtil
|
|||||||
where T : struct
|
where T : struct
|
||||||
{
|
{
|
||||||
// Find the hand entity.
|
// Find the hand entity.
|
||||||
Entity handEntity = Entity.Null;
|
var handEntity = world.FindEntity<T>();
|
||||||
using (var iter = world.Select<T>())
|
|
||||||
{
|
|
||||||
while (iter.MoveNext())
|
|
||||||
{
|
|
||||||
if (iter.CurrentEntity != World.SingletonEntity)
|
|
||||||
{
|
|
||||||
handEntity = iter.CurrentEntity;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (handEntity == Entity.Null)
|
if (handEntity == Entity.Null)
|
||||||
return 0;
|
return 0;
|
||||||
+2
-3
@@ -1,6 +1,6 @@
|
|||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace Blackjack;
|
namespace Game.Blackjack;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Evaluates the player's hand total after each hit.
|
/// Evaluates the player's hand total after each hit.
|
||||||
@@ -8,7 +8,7 @@ namespace Blackjack;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class PlayerBustCheckSystem : ISystem
|
public class PlayerBustCheckSystem : ISystem
|
||||||
{
|
{
|
||||||
public void Run(World world)
|
public void RunImpl(World world)
|
||||||
{
|
{
|
||||||
var state = world.ReadSingleton<GameState>();
|
var state = world.ReadSingleton<GameState>();
|
||||||
if (state.Phase != GamePhase.PlayerTurn)
|
if (state.Phase != GamePhase.PlayerTurn)
|
||||||
@@ -21,6 +21,5 @@ public class PlayerBustCheckSystem : ISystem
|
|||||||
ref var mutableState = ref world.GetSingleton<GameState>();
|
ref var mutableState = ref world.GetSingleton<GameState>();
|
||||||
mutableState.Phase = GamePhase.RoundOver;
|
mutableState.Phase = GamePhase.RoundOver;
|
||||||
mutableState.Result = RoundResult.PlayerBust;
|
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 张势力牌。
|
||||||
|
- 因卡牌效果抓牌时,可以选择抓公共牌或者势力牌。
|
||||||
|
- 势力牌弃掉时,放入独立的势力牌弃牌堆。势力牌堆为空而选择抓势力牌时,将其洗回势力牌堆。
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>Game.TicTacToe.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.TicTacToe\TicTacToe.csproj" />
|
||||||
|
<ProjectReference Include="..\OECS.PlayTest\OECS.PlayTest.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Game.TicTacToe;
|
||||||
|
using OECS;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Game.TicTacToe.Tests;
|
||||||
|
|
||||||
|
public class GameFlowTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void NewGame_HasEmptyBoard()
|
||||||
|
{
|
||||||
|
var (world, _) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
var emptyCount = 0;
|
||||||
|
foreach (var _ in world.Select(new Query<Cell>().Without<Mark>()))
|
||||||
|
emptyCount++;
|
||||||
|
|
||||||
|
emptyCount.Should().Be(9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlaceMark_ClaimsCell()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
var markedCount = 0;
|
||||||
|
foreach (var it in world.Select<Cell, Mark>())
|
||||||
|
{
|
||||||
|
markedCount++;
|
||||||
|
it.Val1.Row.Should().Be(0);
|
||||||
|
it.Val1.Col.Should().Be(0);
|
||||||
|
it.Val2.Player.Should().Be(Player.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
markedCount.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlaceMark_TogglesPlayer()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 }); // X
|
||||||
|
group.RunLogical();
|
||||||
|
world.ReadSingleton<GameState>().CurrentPlayer.Should().Be(Player.O);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceMarkCommand { Row = 1, Col = 0 }); // O
|
||||||
|
group.RunLogical();
|
||||||
|
world.ReadSingleton<GameState>().CurrentPlayer.Should().Be(Player.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlaceMark_CannotOverwriteClaimedCell()
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
|
||||||
|
var marks = new List<(int Row, int Col, Player 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void XWins_Row()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
|
||||||
|
|
||||||
|
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.XWon);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OWins_Column()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (2, 2), (1, 2));
|
||||||
|
|
||||||
|
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.OWon);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void XWins_Diagonal()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (1, 1), (1, 2), (2, 2));
|
||||||
|
|
||||||
|
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.XWon);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Draw_AllCellsFilled()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
TestHelpers.PlayMoves(world, group,
|
||||||
|
(0, 0), (0, 1), (0, 2),
|
||||||
|
(1, 1), (1, 0), (1, 2),
|
||||||
|
(2, 1), (2, 0), (2, 2));
|
||||||
|
|
||||||
|
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.Draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MovesAfterGameOver_AreIgnored()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
|
||||||
|
|
||||||
|
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.XWon);
|
||||||
|
|
||||||
|
var moveCountBefore = world.ReadSingleton<GameState>().MoveCount;
|
||||||
|
world.Commands.Enqueue(new PlaceMarkCommand { Row = 2, Col = 2 });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
world.ReadSingleton<GameState>().MoveCount.Should().Be(moveCountBefore);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SaveLoad_RoundTrips()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0));
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
WorldSerializer.Save(world, stream);
|
||||||
|
stream.Position = 0;
|
||||||
|
|
||||||
|
var world2 = new World();
|
||||||
|
WorldSerializer.Load(world2, stream);
|
||||||
|
|
||||||
|
var state = world2.ReadSingleton<GameState>();
|
||||||
|
state.CurrentPlayer.Should().Be(Player.X);
|
||||||
|
state.MoveCount.Should().Be(2);
|
||||||
|
|
||||||
|
var marks = new List<(int Row, int Col, Player 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)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Game.TicTacToe;
|
||||||
|
using OECS;
|
||||||
|
using OECS.PlayTest;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Game.TicTacToe.Tests;
|
||||||
|
|
||||||
|
public class PlayTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Play_GreedyVsRandom()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
var agentX = new GreedyTicTacToeAgent(GreedyTicTacToeAgent.Strategy.WinOrBlock);
|
||||||
|
var agentO = new RandomTicTacToeAgent();
|
||||||
|
|
||||||
|
var log = RunGame(world, group, agentX, agentO, "TicTacToe: Greedy X vs Random O");
|
||||||
|
|
||||||
|
var path = log.SaveTo("tic_tac_toe_greedy_vs_random.playlog");
|
||||||
|
ReadAndVerify(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Play_RandomVsRandom()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
var agentX = new RandomTicTacToeAgent();
|
||||||
|
var agentO = new RandomTicTacToeAgent();
|
||||||
|
|
||||||
|
var log = RunGame(world, group, agentX, agentO, "TicTacToe: Random X vs Random O");
|
||||||
|
|
||||||
|
var path = log.SaveTo("tic_tac_toe_random_vs_random.playlog");
|
||||||
|
ReadAndVerify(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Play_GreedyVsGreedy()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
var agentX = new GreedyTicTacToeAgent(GreedyTicTacToeAgent.Strategy.WinOrBlock);
|
||||||
|
var agentO = new GreedyTicTacToeAgent(GreedyTicTacToeAgent.Strategy.WinOrBlock);
|
||||||
|
|
||||||
|
var log = RunGame(world, group, agentX, agentO, "TicTacToe: Greedy X vs Greedy O");
|
||||||
|
|
||||||
|
var path = log.SaveTo("tic_tac_toe_greedy_vs_greedy.playlog");
|
||||||
|
ReadAndVerify(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Game Runner ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static PlayLog RunGame(World world, SystemGroup group,
|
||||||
|
IAgent<PlaceMarkCommand> agentX, IAgent<PlaceMarkCommand> agentO, string header)
|
||||||
|
{
|
||||||
|
var log = new PlayLog { Header = header };
|
||||||
|
|
||||||
|
using var capture = new ObservableCapture(world);
|
||||||
|
capture.FormatWith<Mark>(m => m.Player.ToString());
|
||||||
|
|
||||||
|
var moves = new List<string>();
|
||||||
|
while (world.ReadSingleton<GameState>().Status == GameStatus.Playing)
|
||||||
|
{
|
||||||
|
var state = world.ReadSingleton<GameState>();
|
||||||
|
var agent = state.CurrentPlayer == Player.X ? agentX : agentO;
|
||||||
|
var cmd = agent.Decide(world);
|
||||||
|
|
||||||
|
world.Commands.Enqueue(cmd);
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
moves.Add($"{state.CurrentPlayer} → ({cmd.Row},{cmd.Col})");
|
||||||
|
}
|
||||||
|
|
||||||
|
var final = world.ReadSingleton<GameState>();
|
||||||
|
log.Header += $" — Result: {final.Status}";
|
||||||
|
|
||||||
|
log.AddSection("Moves", moves.Select((m, i) => $"{i + 1}. {m}"));
|
||||||
|
log.AddSection("Reactivity", capture.GetLogLines());
|
||||||
|
log.FinalSnapshot = TestHelpers.SnapshotWorld(world);
|
||||||
|
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File I/O ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void ReadAndVerify(string path)
|
||||||
|
{
|
||||||
|
var content = File.ReadAllText(path);
|
||||||
|
|
||||||
|
content.Should().Contain("--- Moves ---");
|
||||||
|
content.Should().Contain("--- Reactivity ---");
|
||||||
|
content.Should().Contain("--- Final State ---");
|
||||||
|
content.Should().Contain("Result:");
|
||||||
|
content.Should().NotContain("Result: Playing");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agents ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class RandomTicTacToeAgent : GreedyAgent<PlaceMarkCommand>
|
||||||
|
{
|
||||||
|
protected override List<PlaceMarkCommand> GetLegalActions(World world)
|
||||||
|
{
|
||||||
|
return TestHelpers.GetEmptyCells(world)
|
||||||
|
.Select(c => new PlaceMarkCommand { Row = c.Row, Col = c.Col })
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GreedyTicTacToeAgent : GreedyAgent<PlaceMarkCommand>
|
||||||
|
{
|
||||||
|
public enum Strategy { WinOrBlock }
|
||||||
|
private readonly Strategy _strategy;
|
||||||
|
private static readonly Random _rng = new();
|
||||||
|
|
||||||
|
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 opponent = me == Player.X ? Player.O : Player.X;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
return 0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Game.TicTacToe;
|
||||||
|
using OECS;
|
||||||
|
using OECS.PlayTest;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace Game.TicTacToe.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Baseline snapshot and log tests for TicTacToe.
|
||||||
|
/// Captures the world state as text and R3 change logs so they can be
|
||||||
|
/// reviewed manually and compared across changes.
|
||||||
|
/// </summary>
|
||||||
|
public class SnapshotTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _output;
|
||||||
|
|
||||||
|
public SnapshotTests(ITestOutputHelper output)
|
||||||
|
{
|
||||||
|
_output = output;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_InitialBoard()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
var snapshot = TestHelpers.SnapshotWorld(world);
|
||||||
|
_output.WriteLine(snapshot);
|
||||||
|
|
||||||
|
snapshot.Should().Contain("GameState: X's turn, 0 moves");
|
||||||
|
snapshot.Should().Contain("Cells: 9 total, 0 marked");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_AfterThreeMoves()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
TestHelpers.PlayMoves(world, group, (0, 0), (1, 1), (2, 2));
|
||||||
|
|
||||||
|
var snapshot = TestHelpers.SnapshotWorld(world);
|
||||||
|
_output.WriteLine(snapshot);
|
||||||
|
|
||||||
|
snapshot.Should().Contain("GameState: O's turn, 3 moves");
|
||||||
|
snapshot.Should().Contain("Cells: 9 total, 3 marked");
|
||||||
|
snapshot.Should().Contain("(0,0): X");
|
||||||
|
snapshot.Should().Contain("(1,1): O");
|
||||||
|
snapshot.Should().Contain("(2,2): X");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_XWins()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
|
||||||
|
|
||||||
|
var snapshot = TestHelpers.SnapshotWorld(world);
|
||||||
|
_output.WriteLine(snapshot);
|
||||||
|
|
||||||
|
snapshot.Should().Contain("GameState: XWon");
|
||||||
|
snapshot.Should().Contain("Cells: 9 total, 5 marked");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_Draw()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
TestHelpers.PlayMoves(world, group,
|
||||||
|
(0, 0), (0, 1), (0, 2),
|
||||||
|
(1, 1), (1, 0), (1, 2),
|
||||||
|
(2, 1), (2, 0), (2, 2));
|
||||||
|
|
||||||
|
var snapshot = TestHelpers.SnapshotWorld(world);
|
||||||
|
_output.WriteLine(snapshot);
|
||||||
|
|
||||||
|
snapshot.Should().Contain("GameState: Draw");
|
||||||
|
snapshot.Should().Contain("Cells: 9 total, 9 marked");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Log_ReactivityDuringGame()
|
||||||
|
{
|
||||||
|
var (world, group) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
using var capture = new ObservableCapture(world);
|
||||||
|
capture.FormatWith<Mark>(m => m.Player.ToString());
|
||||||
|
capture.FormatWith<GameState>(s => $"{s.Status} ({s.CurrentPlayer}, {s.MoveCount} moves)");
|
||||||
|
|
||||||
|
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
|
||||||
|
|
||||||
|
var logText = string.Join("\n", capture.GetLogLines());
|
||||||
|
_output.WriteLine(logText);
|
||||||
|
|
||||||
|
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) = TestHelpers.SetupGame();
|
||||||
|
|
||||||
|
TestHelpers.PlayMoves(world, group, (0, 0), (1, 1), (2, 2));
|
||||||
|
|
||||||
|
var before = TestHelpers.SnapshotWorld(world);
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
WorldSerializer.Save(world, stream);
|
||||||
|
stream.Position = 0;
|
||||||
|
|
||||||
|
var world2 = new World();
|
||||||
|
WorldSerializer.Load(world2, stream);
|
||||||
|
|
||||||
|
var after = TestHelpers.SnapshotWorld(world2);
|
||||||
|
|
||||||
|
_output.WriteLine("=== Before ===");
|
||||||
|
_output.WriteLine(before);
|
||||||
|
_output.WriteLine("=== After ===");
|
||||||
|
_output.WriteLine(after);
|
||||||
|
|
||||||
|
after.Should().Be(before);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-19
@@ -1,7 +1,7 @@
|
|||||||
using MessagePack;
|
using MessagePack;
|
||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace TicTacToe;
|
namespace Game.TicTacToe;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Places the current player's mark on the cell at (Row, Col).
|
/// Places the current player's mark on the cell at (Row, Col).
|
||||||
@@ -15,36 +15,25 @@ public struct PlaceMarkCommand : ICommand
|
|||||||
|
|
||||||
public void Execute(World world)
|
public void Execute(World world)
|
||||||
{
|
{
|
||||||
ref var state = ref world.GetSingleton<GameState>();
|
if (world.ReadSingleton<GameState>().Status != GameStatus.Playing)
|
||||||
|
|
||||||
if (state.Status != GameStatus.Playing)
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Find the cell entity at (Row, Col) that has no Mark.
|
// 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;
|
Entity? target = null;
|
||||||
|
|
||||||
using var iter = world.Select<Cell>(query);
|
ref var state = ref world.GetSingleton<GameState>();
|
||||||
while (iter.MoveNext())
|
foreach(var iter in world.Select(new Query<Cell>().Without<Mark>())){
|
||||||
{
|
if (iter.Val1.Row != Row || iter.Val1.Col != Col) continue;
|
||||||
if (iter.Current1.Row == Row && iter.Current1.Col == Col)
|
target = iter.Entity;
|
||||||
{
|
break;
|
||||||
target = iter.CurrentEntity;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target == null)
|
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 });
|
world.AddComponent(target.Value, new Mark { Player = state.CurrentPlayer });
|
||||||
|
|
||||||
// Advance turn.
|
|
||||||
state.MoveCount++;
|
state.MoveCount++;
|
||||||
state.CurrentPlayer = state.CurrentPlayer == Player.X ? Player.O : Player.X;
|
state.CurrentPlayer = state.CurrentPlayer == Player.X ? Player.O : Player.X;
|
||||||
world.MarkModified<GameState>(World.SingletonEntity);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
using MessagePack;
|
using MessagePack;
|
||||||
|
|
||||||
namespace TicTacToe;
|
namespace Game.TicTacToe;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Identifies a board position. One entity per cell.
|
/// Identifies a board position. One entity per cell.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct Cell
|
public record struct Cell
|
||||||
{
|
{
|
||||||
[Key(0)] public int Row;
|
[Key(0)] public int Row;
|
||||||
[Key(1)] public int Col;
|
[Key(1)] public int Col;
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
using MessagePack;
|
using MessagePack;
|
||||||
|
|
||||||
namespace TicTacToe;
|
namespace Game.TicTacToe;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A mark placed on a cell by a player.
|
/// A mark placed on a cell by a player.
|
||||||
/// Only present on cells that have been claimed.
|
/// Only present on cells that have been claimed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct Mark
|
public record struct Mark
|
||||||
{
|
{
|
||||||
[Key(0)] public Player Player;
|
[Key(0)] public Player Player;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace TicTacToe;
|
namespace Game.TicTacToe;
|
||||||
|
|
||||||
public enum Player : byte
|
public enum Player : byte
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace TicTacToe;
|
namespace Game.TicTacToe;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Minimal CSV loader that creates entities from a CSV file.
|
/// Minimal CSV loader that creates entities from a CSV file.
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
using MessagePack;
|
using MessagePack;
|
||||||
|
|
||||||
namespace TicTacToe;
|
namespace Game.TicTacToe;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Global game state stored on the singleton entity.
|
/// Global game state stored on the singleton entity.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public struct GameState
|
public record struct GameState
|
||||||
{
|
{
|
||||||
[Key(0)] public Player CurrentPlayer;
|
[Key(0)] public Player CurrentPlayer;
|
||||||
[Key(1)] public GameStatus Status;
|
[Key(1)] public GameStatus Status;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
namespace TicTacToe;
|
namespace Game.TicTacToe;
|
||||||
|
|
||||||
public enum GameStatus : byte
|
public enum GameStatus : byte
|
||||||
{
|
{
|
||||||
+10
-18
@@ -1,6 +1,6 @@
|
|||||||
using OECS;
|
using OECS;
|
||||||
|
|
||||||
namespace TicTacToe;
|
namespace Game.TicTacToe;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// After each move, checks whether the game has been won or drawn.
|
/// After each move, checks whether the game has been won or drawn.
|
||||||
@@ -8,22 +8,18 @@ namespace TicTacToe;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class WinCheckSystem : ISystem
|
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)
|
if (world.ReadSingleton<GameState>().Status != GameStatus.Playing)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
ref var state = ref world.GetSingleton<GameState>();
|
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];
|
var grid = new Player[3, 3];
|
||||||
|
foreach (var iter in world.Select<Cell, Mark>())
|
||||||
using var iter = world.Select<Cell, Mark>();
|
|
||||||
while (iter.MoveNext())
|
|
||||||
{
|
{
|
||||||
grid[iter.Current1.Row, iter.Current1.Col] = iter.Current2.Player;
|
grid[iter.Val1.Row, iter.Val1.Col] = iter.Val2.Player;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check rows.
|
// Check rows.
|
||||||
@@ -31,7 +27,7 @@ public class WinCheckSystem : ISystem
|
|||||||
{
|
{
|
||||||
if (TryGetWinner(grid[r, 0], grid[r, 1], grid[r, 2], out var winner))
|
if (TryGetWinner(grid[r, 0], grid[r, 1], grid[r, 2], out var winner))
|
||||||
{
|
{
|
||||||
SetWinner(world, ref state, winner);
|
SetWinner(ref state, winner);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,7 +37,7 @@ public class WinCheckSystem : ISystem
|
|||||||
{
|
{
|
||||||
if (TryGetWinner(grid[0, c], grid[1, c], grid[2, c], out var winner))
|
if (TryGetWinner(grid[0, c], grid[1, c], grid[2, c], out var winner))
|
||||||
{
|
{
|
||||||
SetWinner(world, ref state, winner);
|
SetWinner(ref state, winner);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,21 +45,18 @@ public class WinCheckSystem : ISystem
|
|||||||
// Check diagonals.
|
// Check diagonals.
|
||||||
if (TryGetWinner(grid[0, 0], grid[1, 1], grid[2, 2], out var diag1))
|
if (TryGetWinner(grid[0, 0], grid[1, 1], grid[2, 2], out var diag1))
|
||||||
{
|
{
|
||||||
SetWinner(world, ref state, diag1);
|
SetWinner(ref state, diag1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (TryGetWinner(grid[0, 2], grid[1, 1], grid[2, 0], out var diag2))
|
if (TryGetWinner(grid[0, 2], grid[1, 1], grid[2, 0], out var diag2))
|
||||||
{
|
{
|
||||||
SetWinner(world, ref state, diag2);
|
SetWinner(ref state, diag2);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check draw.
|
// Check draw.
|
||||||
if (state.MoveCount >= 9)
|
if (state.MoveCount >= 9)
|
||||||
{
|
|
||||||
state.Status = GameStatus.Draw;
|
state.Status = GameStatus.Draw;
|
||||||
world.MarkModified<GameState>(World.SingletonEntity);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryGetWinner(Player a, Player b, Player c, out Player winner)
|
private static bool TryGetWinner(Player a, Player b, Player c, out Player winner)
|
||||||
@@ -77,9 +70,8 @@ public class WinCheckSystem : ISystem
|
|||||||
return false;
|
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;
|
state.Status = winner == Player.X ? GameStatus.XWon : GameStatus.OWon;
|
||||||
world.MarkModified<GameState>(World.SingletonEntity);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Library</OutputType>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<RootNamespace>Game.TicTacToe</RootNamespace>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<RootNamespace>TicTacToe</RootNamespace>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
|
<ProjectReference Include="..\OECS\OECS.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\OECS.SourceGen\OECS.SourceGen.csproj"
|
<ProjectReference Include="..\OECS.SourceGen\OECS.SourceGen.csproj"
|
||||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-67
@@ -38,13 +38,23 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
|||||||
// Query building
|
// Query building
|
||||||
"With",
|
"With",
|
||||||
"Without",
|
"Without",
|
||||||
|
|
||||||
|
// Iteration and relationships
|
||||||
|
"Select",
|
||||||
|
"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)
|
public void Initialize(IncrementalGeneratorInitializationContext context)
|
||||||
{
|
{
|
||||||
// Collect all generic method invocations that reference component types.
|
// Collect all generic method invocations that reference component types.
|
||||||
@@ -53,17 +63,9 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
|||||||
predicate: IsCandidateInvocation,
|
predicate: IsCandidateInvocation,
|
||||||
transform: ExtractComponentType)
|
transform: ExtractComponentType)
|
||||||
.Where(t => t.FullyQualifiedName != null)
|
.Where(t => t.FullyQualifiedName != null)
|
||||||
.Select((t, _) => (t.FullyQualifiedName!, t.AssemblyQualifiedName!));
|
.Select((t, _) => (t.FullyQualifiedName!, t.AssemblyQualifiedName!, t.IsRelationship, t.IsSingleton));
|
||||||
|
|
||||||
// Also collect ForEach type arguments from the World class.
|
var allTypes = invocations.Collect();
|
||||||
var forEachCalls = context.SyntaxProvider
|
|
||||||
.CreateSyntaxProvider(
|
|
||||||
predicate: IsForEachCandidate,
|
|
||||||
transform: ExtractForEachTypes)
|
|
||||||
.Where(list => list.Length > 0)
|
|
||||||
.SelectMany((list, _) => list);
|
|
||||||
|
|
||||||
var allTypes = invocations.Collect().Combine(forEachCalls.Collect());
|
|
||||||
|
|
||||||
context.RegisterSourceOutput(allTypes, GenerateRegistry);
|
context.RegisterSourceOutput(allTypes, GenerateRegistry);
|
||||||
}
|
}
|
||||||
@@ -84,23 +86,9 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
|||||||
return false;
|
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, bool IsSingleton) ExtractComponentType(
|
||||||
}
|
|
||||||
|
|
||||||
private static (string? FullyQualifiedName, string? AssemblyQualifiedName) ExtractComponentType(
|
|
||||||
GeneratorSyntaxContext ctx, CancellationToken _)
|
GeneratorSyntaxContext ctx, CancellationToken _)
|
||||||
{
|
{
|
||||||
if (ctx.Node is not InvocationExpressionSyntax invocation)
|
if (ctx.Node is not InvocationExpressionSyntax invocation)
|
||||||
@@ -133,53 +121,28 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
|||||||
? $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted))}, {typeSymbol.ContainingAssembly.Name}"
|
? $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted))}, {typeSymbol.ContainingAssembly.Name}"
|
||||||
: fqn;
|
: fqn;
|
||||||
|
|
||||||
return (fqn, aqn);
|
bool isRelationship = typeSymbol.AllInterfaces.Any(i =>
|
||||||
|
i.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::OECS.IRelationship");
|
||||||
|
|
||||||
|
bool isSingleton = _singletonMethods.Contains(genericName.Identifier.Text);
|
||||||
|
|
||||||
|
return (fqn, aqn, isRelationship, isSingleton);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName)> ExtractForEachTypes(
|
|
||||||
GeneratorSyntaxContext ctx, CancellationToken _)
|
|
||||||
{
|
|
||||||
if (ctx.Node is not InvocationExpressionSyntax invocation)
|
|
||||||
return ImmutableArray<(string, string)>.Empty;
|
|
||||||
|
|
||||||
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
|
|
||||||
return ImmutableArray<(string, string)>.Empty;
|
|
||||||
|
|
||||||
if (memberAccess.Name is not GenericNameSyntax genericName)
|
|
||||||
return ImmutableArray<(string, string)>.Empty;
|
|
||||||
|
|
||||||
var types = ImmutableArray.CreateBuilder<(string, string)>();
|
|
||||||
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;
|
|
||||||
types.Add((fqn, aqn));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return types.ToImmutable();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void GenerateRegistry(SourceProductionContext context,
|
private static void GenerateRegistry(SourceProductionContext context,
|
||||||
(ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName)> Left,
|
ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship, bool IsSingleton)> invocations)
|
||||||
ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName)> Right) data)
|
|
||||||
{
|
{
|
||||||
var (invocations, forEachTypes) = data;
|
|
||||||
|
|
||||||
// Collect unique types by fully-qualified name, deduplicating.
|
// Collect unique types by fully-qualified name, deduplicating.
|
||||||
var typeMap = new Dictionary<string, (string Fqn, string Aqn)>();
|
var typeMap = new Dictionary<string, (string Fqn, string Aqn, bool IsRel, bool IsSingleton)>();
|
||||||
foreach (var t in invocations)
|
foreach (var t in invocations)
|
||||||
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName);
|
|
||||||
foreach (var t in forEachTypes)
|
|
||||||
{
|
{
|
||||||
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName);
|
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)
|
if (typeMap.Count == 0)
|
||||||
@@ -197,11 +160,11 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
|||||||
sb.AppendLine(" [ModuleInitializer]");
|
sb.AppendLine(" [ModuleInitializer]");
|
||||||
sb.AppendLine(" public static void Initialize()");
|
sb.AppendLine(" public static void Initialize()");
|
||||||
sb.AppendLine(" {");
|
sb.AppendLine(" {");
|
||||||
sb.AppendLine(" ComponentRegistry.Initialize(new ComponentDescriptor[]");
|
sb.AppendLine(" ComponentRegistry.Register(new ComponentDescriptor[]");
|
||||||
sb.AppendLine(" {");
|
sb.AppendLine(" {");
|
||||||
|
|
||||||
bool first = true;
|
bool first = true;
|
||||||
foreach (var (fqn, aqn) in typeMap.Values.OrderBy(t => t.Fqn))
|
foreach (var (fqn, aqn, isRel, isSingleton) in typeMap.Values.OrderBy(t => t.Fqn))
|
||||||
{
|
{
|
||||||
if (!first)
|
if (!first)
|
||||||
sb.AppendLine(",");
|
sb.AppendLine(",");
|
||||||
@@ -212,7 +175,10 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
|||||||
sb.AppendLine($" type: typeof({fqn}),");
|
sb.AppendLine($" type: typeof({fqn}),");
|
||||||
sb.AppendLine($" serialize: obj => MessagePackSerializer.Serialize(({fqn})obj),");
|
sb.AppendLine($" serialize: obj => MessagePackSerializer.Serialize(({fqn})obj),");
|
||||||
sb.AppendLine($" deserializeAndAdd: (world, entity, data) =>");
|
sb.AppendLine($" deserializeAndAdd: (world, entity, data) =>");
|
||||||
sb.AppendLine($" world.AddComponent(entity, MessagePackSerializer.Deserialize<{fqn}>(data))");
|
sb.AppendLine($" world.AddComponent(entity, MessagePackSerializer.Deserialize<{fqn}>(data)),");
|
||||||
|
sb.AppendLine($" deserialize: data => MessagePackSerializer.Deserialize<{fqn}>(data),");
|
||||||
|
sb.AppendLine($" isSingleton: {(isSingleton ? "true" : "false")}");
|
||||||
|
|
||||||
sb.Append(" )");
|
sb.Append(" )");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>netstandard2.0</TargetFramework>
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<LangVersion>12</LangVersion>
|
|
||||||
<RootNamespace>OECS.SourceGen</RootNamespace>
|
<RootNamespace>OECS.SourceGen</RootNamespace>
|
||||||
<AssemblyName>OECS.SourceGen</AssemblyName>
|
<AssemblyName>OECS.SourceGen</AssemblyName>
|
||||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||||
@@ -114,13 +114,11 @@ public class CommandTests
|
|||||||
|
|
||||||
world.ExecuteCommands();
|
world.ExecuteCommands();
|
||||||
|
|
||||||
var query = world.Query().With<TestPosition>().With<TestHealth>().Build();
|
|
||||||
var positions = new List<(float X, float Y, int Health)>();
|
var positions = new List<(float X, float Y, int Health)>();
|
||||||
|
foreach (var it in world.Select<TestPosition, TestHealth>())
|
||||||
world.ForEach(query, (Entity e, ref TestPosition pos, ref TestHealth hp) =>
|
|
||||||
{
|
{
|
||||||
positions.Add((pos.X, pos.Y, hp.Value));
|
positions.Add((it.Val1.X, it.Val1.Y, it.Val2.Value));
|
||||||
});
|
}
|
||||||
|
|
||||||
positions.Should().BeEquivalentTo([
|
positions.Should().BeEquivalentTo([
|
||||||
(1, 2, 100),
|
(1, 2, 100),
|
||||||
@@ -151,13 +149,11 @@ public class CommandTests
|
|||||||
world.Commands.Enqueue(new ChainedCommand { X = 0, Y = 0 });
|
world.Commands.Enqueue(new ChainedCommand { X = 0, Y = 0 });
|
||||||
world.ExecuteCommands();
|
world.ExecuteCommands();
|
||||||
|
|
||||||
var query = world.Query().With<TestPosition>().Build();
|
|
||||||
var positions = new List<(float X, float Y)>();
|
var positions = new List<(float X, float Y)>();
|
||||||
|
foreach (var it in world.Select<TestPosition>())
|
||||||
world.ForEach(query, (Entity e, ref TestPosition pos) =>
|
|
||||||
{
|
{
|
||||||
positions.Add((pos.X, pos.Y));
|
positions.Add((it.Val1.X, it.Val1.Y));
|
||||||
});
|
}
|
||||||
|
|
||||||
positions.Should().BeEquivalentTo([(10, 20)]);
|
positions.Should().BeEquivalentTo([(10, 20)]);
|
||||||
}
|
}
|
||||||
@@ -173,9 +169,9 @@ public class CommandTests
|
|||||||
|
|
||||||
world.ExecuteCommands();
|
world.ExecuteCommands();
|
||||||
|
|
||||||
var query = world.Query().With<TestPosition>().Build();
|
|
||||||
var count = 0;
|
var count = 0;
|
||||||
world.ForEach(query, (Entity e, ref TestPosition pos) => count++);
|
foreach (var it in world.Select<TestPosition>())
|
||||||
|
count++;
|
||||||
count.Should().Be(2);
|
count.Should().Be(2);
|
||||||
|
|
||||||
world.Commands.Errors.Should().HaveCount(1);
|
world.Commands.Errors.Should().HaveCount(1);
|
||||||
@@ -222,9 +218,9 @@ public class CommandTests
|
|||||||
|
|
||||||
world.ExecuteCommands();
|
world.ExecuteCommands();
|
||||||
|
|
||||||
var query = world.Query().With<TestPosition>().Build();
|
|
||||||
var count = 0;
|
var count = 0;
|
||||||
world.ForEach(query, (Entity e, ref TestPosition pos) => count++);
|
foreach (var it in world.Select<TestPosition>())
|
||||||
|
count++;
|
||||||
count.Should().Be(0);
|
count.Should().Be(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +232,7 @@ public class CommandTests
|
|||||||
|
|
||||||
group.Add(new CommandEnqueueSystem(world, new SpawnCommand { X = 1, Y = 2, Health = 100 }));
|
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.Add(observer);
|
||||||
|
|
||||||
group.RunLogical();
|
group.RunLogical();
|
||||||
@@ -255,7 +251,7 @@ public class CommandTests
|
|||||||
_command = command;
|
_command = command;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Run(World world)
|
public void RunImpl(World world)
|
||||||
{
|
{
|
||||||
_world.Commands.Enqueue(_command);
|
_world.Commands.Enqueue(_command);
|
||||||
}
|
}
|
||||||
@@ -263,20 +259,12 @@ public class CommandTests
|
|||||||
|
|
||||||
private class EntityCountObserver : ISystem
|
private class EntityCountObserver : ISystem
|
||||||
{
|
{
|
||||||
private readonly QueryDescriptor _query;
|
|
||||||
public int SeenCount { get; private set; }
|
public int SeenCount { get; private set; }
|
||||||
|
|
||||||
public EntityCountObserver(World world)
|
public void RunImpl(World world)
|
||||||
{
|
{
|
||||||
_query = world.Query().With<TestPosition>().Build();
|
foreach (var it in world.Select<TestPosition>())
|
||||||
}
|
|
||||||
|
|
||||||
public void Run(World world)
|
|
||||||
{
|
|
||||||
world.ForEach(_query, (Entity e, ref TestPosition pos) =>
|
|
||||||
{
|
|
||||||
SeenCount++;
|
SeenCount++;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,7 +111,6 @@ public class EdgeCaseTests
|
|||||||
sources.Add(source);
|
sources.Add(source);
|
||||||
world.AddComponent(source, new Relationship<ChildOf, ParentOf>
|
world.AddComponent(source, new Relationship<ChildOf, ParentOf>
|
||||||
{
|
{
|
||||||
Source = source,
|
|
||||||
Target = target
|
Target = target
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -154,45 +153,6 @@ public class EdgeCaseTests
|
|||||||
c.Kind == ChangeKind.EntityRemoved && c.Entity == entity);
|
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 ────────────────────────────────
|
// ── Query with 4, 5, 6 components ────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -205,18 +165,14 @@ public class EdgeCaseTests
|
|||||||
world.AddComponent(entity, new Health { Value = 100 });
|
world.AddComponent(entity, new Health { Value = 100 });
|
||||||
world.AddComponent(entity, new Frozen());
|
world.AddComponent(entity, new Frozen());
|
||||||
|
|
||||||
var query = world.Query()
|
|
||||||
.With<Position>().With<Velocity>().With<Health>().With<Frozen>()
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
var count = 0;
|
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++;
|
count++;
|
||||||
pos.X.Should().Be(1);
|
it.Val1.X.Should().Be(1);
|
||||||
vel.Y.Should().Be(4);
|
it.Val2.Y.Should().Be(4);
|
||||||
hp.Value.Should().Be(100);
|
it.Val3.Value.Should().Be(100);
|
||||||
});
|
}
|
||||||
count.Should().Be(1);
|
count.Should().Be(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,15 +187,11 @@ public class EdgeCaseTests
|
|||||||
world.AddComponent(entity, new Frozen());
|
world.AddComponent(entity, new Frozen());
|
||||||
world.AddComponent(entity, new Burning());
|
world.AddComponent(entity, new Burning());
|
||||||
|
|
||||||
var query = world.Query()
|
|
||||||
.With<Position>().With<Velocity>().With<Health>().With<Frozen>().With<Burning>()
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
var count = 0;
|
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++;
|
||||||
});
|
}
|
||||||
count.Should().Be(1);
|
count.Should().Be(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,17 +207,11 @@ public class EdgeCaseTests
|
|||||||
world.AddComponent(entity, new Burning());
|
world.AddComponent(entity, new Burning());
|
||||||
world.AddComponent(entity, new Poisoned());
|
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;
|
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, Frozen, Burning, Poisoned>())
|
||||||
ref Frozen f, ref Burning b, ref Poisoned p) =>
|
|
||||||
{
|
{
|
||||||
count++;
|
count++;
|
||||||
});
|
}
|
||||||
count.Should().Be(1);
|
count.Should().Be(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,13 +229,13 @@ public class EdgeCaseTests
|
|||||||
world.AddComponent(b, new Frozen());
|
world.AddComponent(b, new Frozen());
|
||||||
|
|
||||||
// Without<Frozen> filters out entity b which has 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>();
|
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]);
|
results.Should().BeEquivalentTo([a]);
|
||||||
}
|
}
|
||||||
@@ -417,16 +363,15 @@ public class EdgeCaseTests
|
|||||||
world.AddComponent(a, new Position { X = 1, Y = 2 });
|
world.AddComponent(a, new Position { X = 1, Y = 2 });
|
||||||
world.AddComponent(b, new Position { X = 3, Y = 4 });
|
world.AddComponent(b, new Position { X = 3, Y = 4 });
|
||||||
|
|
||||||
var query = world.Query().With<Position>().Build();
|
|
||||||
var seen = new List<Entity>();
|
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.
|
// Add a component to the other entity during iteration.
|
||||||
// This should not affect the current 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]);
|
seen.Should().BeEquivalentTo([a, b]);
|
||||||
}
|
}
|
||||||
@@ -440,96 +385,123 @@ public class EdgeCaseTests
|
|||||||
world.AddComponent(a, new Position { X = 1, Y = 2 });
|
world.AddComponent(a, new Position { X = 1, Y = 2 });
|
||||||
world.AddComponent(b, new Position { X = 3, Y = 4 });
|
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
|
// Destroying an entity during iteration: the sparse set uses
|
||||||
// swap-remove, so the destroyed entity's slot is replaced by
|
// 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 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).
|
// the destroyed entity (which we won't since it's swap-removed).
|
||||||
var act = () =>
|
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();
|
act.Should().NotThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── QueryDescriptor.With enforced during ForEach ─────────────────
|
// ── ReorderSources edge cases ────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ForEach_Throws_WhenTypeParamsDivergeFromQueryWith()
|
public void ReorderSources_WithDuplicateEntities_ReordersToMatch()
|
||||||
{
|
{
|
||||||
var world = new World();
|
var world = new World();
|
||||||
var entity = world.CreateEntity();
|
var target = world.CreateEntity();
|
||||||
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
|
||||||
world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
|
|
||||||
|
|
||||||
// Build a query requiring Position + Velocity, but iterate only Position.
|
var a = world.CreateEntity();
|
||||||
var query = world.Query().With<Position>().With<Velocity>().Build();
|
var b = world.CreateEntity();
|
||||||
|
|
||||||
var act = () =>
|
world.AddComponent(a, new Relationship<ChildOf, ParentOf> { Target = target });
|
||||||
{
|
world.AddComponent(b, new Relationship<ChildOf, ParentOf> { Target = target });
|
||||||
world.ForEach(query, (Entity e, ref Position pos) => { });
|
|
||||||
};
|
// Reorder with duplicate entries — should still work (last occurrence wins).
|
||||||
act.Should().Throw<InvalidOperationException>()
|
world.ReorderSources<Relationship<ChildOf, ParentOf>>(target, [b, a, b]);
|
||||||
.WithMessage("*ForEach*type*Position*Velocity*");
|
|
||||||
|
var result = world.GetSources<Relationship<ChildOf, ParentOf>>(target);
|
||||||
|
result.Should().Equal([b, a, b]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 world = new World();
|
||||||
var entity = world.CreateEntity();
|
var entity = world.CreateEntity();
|
||||||
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||||
world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
|
world.PostChanges(); // Clear pending
|
||||||
world.AddComponent(entity, new Health { Value = 100 });
|
|
||||||
|
|
||||||
// Build a query requiring 3 components, but iterate only 2.
|
var collector = new ChangeCollector();
|
||||||
var query = world.Query().With<Position>().With<Velocity>().With<Health>().Build();
|
using var sub = world.ObserveComponentChanges<Position>().Subscribe(collector.ToObserver());
|
||||||
|
|
||||||
var act = () =>
|
// Run a system that only reads via ReadComponent — no auto-marking.
|
||||||
{
|
var group = new SystemGroup(world);
|
||||||
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => { });
|
group.Add(new ReadOnlySystem(world, entity));
|
||||||
};
|
group.RunLogical();
|
||||||
act.Should().Throw<InvalidOperationException>()
|
|
||||||
.WithMessage("*ForEach*type*Health*");
|
collector.Changes.Should().BeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ForEach_Throws_WhenTypeParamsAreSupersetOfQueryWith()
|
public void ReadSingleton_DoesNotAutoMarkModified()
|
||||||
{
|
{
|
||||||
var world = new World();
|
var world = new World();
|
||||||
var entity = world.CreateEntity();
|
world.SetSingleton(new Position { X = 1, Y = 2 });
|
||||||
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
world.PostChanges(); // Clear pending
|
||||||
|
|
||||||
// Build a query requiring only Position, but iterate Position + Velocity.
|
var collector = new ChangeCollector();
|
||||||
var query = world.Query().With<Position>().Build();
|
using var sub = world.ObserveComponentChanges<Position>().Subscribe(collector.ToObserver());
|
||||||
|
|
||||||
var act = () =>
|
// Run a system that only reads the singleton via ReadSingleton.
|
||||||
{
|
var group = new SystemGroup(world);
|
||||||
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => { });
|
group.Add(new ReadSingletonSystem(world));
|
||||||
};
|
group.RunLogical();
|
||||||
act.Should().Throw<InvalidOperationException>()
|
|
||||||
.WithMessage("*ForEach*type*Velocity*");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
collector.Changes.Should().BeEmpty();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────
|
||||||
@@ -546,4 +518,39 @@ public class EdgeCaseTests
|
|||||||
// Phantom types for relationship tests.
|
// Phantom types for relationship tests.
|
||||||
public struct ChildOf { }
|
public struct ChildOf { }
|
||||||
public struct ParentOf { }
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<RootNamespace>OECS.Tests</RootNamespace>
|
<RootNamespace>OECS.Tests</RootNamespace>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
@@ -20,11 +17,11 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
|
<ProjectReference Include="..\OECS\OECS.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\OECS.SourceGen\OECS.SourceGen.csproj"
|
<ProjectReference Include="..\OECS.SourceGen\OECS.SourceGen.csproj"
|
||||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
@@ -22,13 +22,9 @@ public class QueryTests
|
|||||||
world.AddComponent(b, new Position { X = 3, Y = 4 });
|
world.AddComponent(b, new Position { X = 3, Y = 4 });
|
||||||
// c has no Position
|
// c has no Position
|
||||||
|
|
||||||
var query = world.Query().With<Position>().Build();
|
|
||||||
var results = new List<Entity>();
|
var results = new List<Entity>();
|
||||||
|
foreach (var it in world.Select<Position>())
|
||||||
world.ForEach(query, (Entity entity, ref Position pos) =>
|
results.Add(it.Entity);
|
||||||
{
|
|
||||||
results.Add(entity);
|
|
||||||
});
|
|
||||||
|
|
||||||
results.Should().BeEquivalentTo([a, b]);
|
results.Should().BeEquivalentTo([a, b]);
|
||||||
}
|
}
|
||||||
@@ -50,13 +46,9 @@ public class QueryTests
|
|||||||
world.AddComponent(c, new Velocity { X = 0, Y = 1 });
|
world.AddComponent(c, new Velocity { X = 0, Y = 1 });
|
||||||
// c has no Position
|
// c has no Position
|
||||||
|
|
||||||
var query = world.Query().With<Position>().With<Velocity>().Build();
|
|
||||||
var results = new List<Entity>();
|
var results = new List<Entity>();
|
||||||
|
foreach (var it in world.Select<Position, Velocity>())
|
||||||
world.ForEach(query, (Entity entity, ref Position pos, ref Velocity vel) =>
|
results.Add(it.Entity);
|
||||||
{
|
|
||||||
results.Add(entity);
|
|
||||||
});
|
|
||||||
|
|
||||||
results.Should().BeEquivalentTo([a]);
|
results.Should().BeEquivalentTo([a]);
|
||||||
}
|
}
|
||||||
@@ -72,13 +64,10 @@ public class QueryTests
|
|||||||
world.AddComponent(b, new Position { X = 3, Y = 4 });
|
world.AddComponent(b, new Position { X = 3, Y = 4 });
|
||||||
world.AddComponent(b, new Frozen());
|
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>();
|
var results = new List<Entity>();
|
||||||
|
foreach (var it in world.Select(query))
|
||||||
world.ForEach(query, (Entity entity, ref Position pos) =>
|
results.Add(it.Entity);
|
||||||
{
|
|
||||||
results.Add(entity);
|
|
||||||
});
|
|
||||||
|
|
||||||
results.Should().BeEquivalentTo([a]);
|
results.Should().BeEquivalentTo([a]);
|
||||||
}
|
}
|
||||||
@@ -87,14 +76,9 @@ public class QueryTests
|
|||||||
public void Query_EmptyResult_WhenNoEntitiesMatch()
|
public void Query_EmptyResult_WhenNoEntitiesMatch()
|
||||||
{
|
{
|
||||||
var world = new World();
|
var world = new World();
|
||||||
var query = world.Query().With<Position>().Build();
|
|
||||||
var count = 0;
|
var count = 0;
|
||||||
|
foreach (var it in world.Select<Position>())
|
||||||
world.ForEach(query, (Entity entity, ref Position pos) =>
|
|
||||||
{
|
|
||||||
count++;
|
count++;
|
||||||
});
|
|
||||||
|
|
||||||
count.Should().Be(0);
|
count.Should().Be(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +89,8 @@ public class QueryTests
|
|||||||
var entity = world.CreateEntity();
|
var entity = world.CreateEntity();
|
||||||
world.AddComponent(entity, new Position { X = 0, Y = 0 });
|
world.AddComponent(entity, new Position { X = 0, Y = 0 });
|
||||||
|
|
||||||
var query = world.Query().With<Position>().Build();
|
foreach (var it in world.Select<Position>())
|
||||||
|
it.Ref1.X = 42;
|
||||||
world.ForEach(query, (Entity e, ref Position pos) =>
|
|
||||||
{
|
|
||||||
pos.X = 42;
|
|
||||||
});
|
|
||||||
|
|
||||||
ref var pos = ref world.GetComponent<Position>(entity);
|
ref var pos = ref world.GetComponent<Position>(entity);
|
||||||
pos.X.Should().Be(42);
|
pos.X.Should().Be(42);
|
||||||
@@ -128,13 +108,9 @@ public class QueryTests
|
|||||||
|
|
||||||
world.DestroyEntity(a);
|
world.DestroyEntity(a);
|
||||||
|
|
||||||
var query = world.Query().With<Position>().Build();
|
|
||||||
var results = new List<Entity>();
|
var results = new List<Entity>();
|
||||||
|
foreach (var it in world.Select<Position>())
|
||||||
world.ForEach(query, (Entity entity, ref Position pos) =>
|
results.Add(it.Entity);
|
||||||
{
|
|
||||||
results.Add(entity);
|
|
||||||
});
|
|
||||||
|
|
||||||
results.Should().BeEquivalentTo([b]);
|
results.Should().BeEquivalentTo([b]);
|
||||||
}
|
}
|
||||||
@@ -149,23 +125,31 @@ public class QueryTests
|
|||||||
world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
|
world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
|
||||||
world.AddComponent(entity, new Health { Value = 100 });
|
world.AddComponent(entity, new Health { Value = 100 });
|
||||||
|
|
||||||
var query = world.Query()
|
|
||||||
.With<Position>()
|
|
||||||
.With<Velocity>()
|
|
||||||
.With<Health>()
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
var count = 0;
|
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++;
|
count++;
|
||||||
pos.X.Should().Be(1);
|
it.Val1.X.Should().Be(1);
|
||||||
vel.Y.Should().Be(4);
|
it.Val2.Y.Should().Be(4);
|
||||||
hp.Value.Should().Be(100);
|
it.Val3.Value.Should().Be(100);
|
||||||
});
|
}
|
||||||
|
|
||||||
count.Should().Be(1);
|
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
|
public class SystemTests
|
||||||
@@ -175,23 +159,16 @@ public class SystemTests
|
|||||||
|
|
||||||
private class MovementSystem : ITickedSystem
|
private class MovementSystem : ITickedSystem
|
||||||
{
|
{
|
||||||
private readonly QueryDescriptor _query;
|
public void RunImpl(World world) => RunImpl(world, Tick.Logical());
|
||||||
|
|
||||||
public MovementSystem(World world)
|
public void RunImpl(World world, Tick tick)
|
||||||
{
|
|
||||||
_query = world.Query().With<Position>().With<Velocity>().Build();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Run(World world) => Run(world, Tick.Logical());
|
|
||||||
|
|
||||||
public void Run(World world, Tick tick)
|
|
||||||
{
|
{
|
||||||
float dt = tick.DeltaTime;
|
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;
|
it.Ref1.X += it.Val2.X * dt;
|
||||||
pos.Y += vel.Y * dt;
|
it.Ref1.Y += it.Val2.Y * dt;
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,8 +177,7 @@ public class SystemTests
|
|||||||
{
|
{
|
||||||
var world = new World();
|
var world = new World();
|
||||||
var group = new SystemGroup(world);
|
var group = new SystemGroup(world);
|
||||||
var system = new MovementSystem(world);
|
group.Add(new MovementSystem());
|
||||||
group.Add(system);
|
|
||||||
|
|
||||||
var entity = world.CreateEntity();
|
var entity = world.CreateEntity();
|
||||||
world.AddComponent(entity, new Position { X = 0, Y = 0 });
|
world.AddComponent(entity, new Position { X = 0, Y = 0 });
|
||||||
@@ -220,7 +196,7 @@ public class SystemTests
|
|||||||
var world = new World();
|
var world = new World();
|
||||||
var group = new SystemGroup(world);
|
var group = new SystemGroup(world);
|
||||||
|
|
||||||
var system = new TestLogicalSystem(world);
|
var system = new TestLogicalSystem();
|
||||||
group.Add(system);
|
group.Add(system);
|
||||||
|
|
||||||
group.RunLogical();
|
group.RunLogical();
|
||||||
@@ -235,67 +211,12 @@ public class SystemTests
|
|||||||
public bool WasCalled { get; private set; }
|
public bool WasCalled { get; private set; }
|
||||||
public Tick ReceivedTick { 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 RunImpl(World world, Tick tick)
|
||||||
|
|
||||||
public void Run(World world, Tick tick)
|
|
||||||
{
|
{
|
||||||
WasCalled = true;
|
WasCalled = true;
|
||||||
ReceivedTick = tick;
|
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user