Compare commits

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

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

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

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

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

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

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

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

This change simplifies the relationship model, reduces memory usage,
and removes the need for reflection-based fixups in the serializer.
2026-07-20 22:59:27 +08:00
hypercross 2de735498c feat: implement CardWars game engine
Implement the core CardWars game logic using an ECS-based architecture.
This includes:
- Card effect registry and various card effect implementations.
- Game phase management (Setup, Play, Flip, Scoring, Cleanup).
- Command system for player actions (PlayCard, FlipCard, etc.).
- Component-based game state and entity relationships.
- Automated game setup and scoring systems.
- Unit tests for game setup, card effects, and play logs.
2026-07-20 22:52:28 +08:00
hypercross 3b08138580 feat: add agent skill documentation
Add new skill documentation files for developing OECS, testing games,
and writing games. Also update the API surface documentation to include
guidance on component and command definitions.
2026-07-20 18:01:46 +08:00
hypercross b066ac2eba docs: update documentation and implementation plan
Update the API surface and implementation plan to reflect architectural
changes, including new iterator overloads, system design refinements,
and the introduction of a source generator.
2026-07-20 17:44:25 +08:00
hypercross 10c4caed90 test: improve blackjack play tests and logging
Refactor `RunRound` to `RunUntilDone` to allow for multi-round
simulations
until a win/loss condition is met. Added round summaries, win/loss/push
tracking, and improved log formatting to provide better visibility into
agent performance.
2026-07-20 17:37:23 +08:00
hypercross f8d0fe314c test: add play tests for Blackjack and TicTacToe
Implement play tests for Blackjack and TicTacToe that simulate games
using various AI agents (Basic Strategy, Random, Greedy) and record
the results, decisions, and reactivity into playlogs for debugging
and LLM analysis. Update documentation to reflect the purpose of
these tests.
2026-07-20 17:22:07 +08:00
hypercross 343bcd5b2e refactor: convert component structs to record structs 2026-07-20 17:15:17 +08:00
hypercross e1fb197474 feat(oecs): improve serialization and add snapshot tests
Enhance the OECS framework to support more robust component
deserialization during world serialization. This includes adding
boxed component support and a runtime fallback for component
discovery.

Additionally, introduces snapshot testing patterns for Blackjack
and TicTacToe to allow for manual verification of world state and
reactivity logs.
2026-07-20 17:05:17 +08:00
hypercross b4661e714e test: add unit tests for Blackjack and TicTacToe
- Add xUnit test projects for both games
- Implement game flow tests for Blackjack
- Implement game flow tests for TicTacToe
- Rename namespaces and projects to follow `Game.*` convention
- Add documentation for testing games
2026-07-20 16:41:10 +08:00
hypercross 7946f4bafd refactor: reorganize project structure and move examples
- Move examples (Blackjack, TicTacToe) to the root directory
- Convert example projects from Console applications to Libraries
- Add Directory.Build.props for shared build settings
- Update solution file and project references to reflect new paths
2026-07-20 16:30:02 +08:00
hypercross 5594515a53 feat: add local NuGet development workflow
Add a local NuGet feed configuration, a Directory.Build.props
template for Godot users, and automate source generator bundling
into the NuGet package.
2026-07-19 00:30:36 +08:00
hypercross 1da0ca30d3 refactor: replace boxed component addition with typed delegate 2026-07-18 23:44:10 +08:00
hypercross 96ba037432 feat: add component discovery source generator
Introduce an incremental source generator that automatically discovers
component types used with the World API. This allows the
WorldSerializer to perform serialization and deserialization without
relying on reflection, improving performance and stability.
2026-07-18 23:41:28 +08:00
hypercross 713d578179 feat: add Blackjack example 2026-07-18 23:26:56 +08:00
hypercross b551a67915 feat: add simplified Select extension methods to EntityIterator
Add overloads for Select that allow iterating over components without
manually building a QueryDescriptor, simplifying usage in systems.
2026-07-18 22:27:14 +08:00
hypercross df388ee9b3 refactor: remove QueryDescriptor from ISystem interface
Remove the mandatory Query property from the ISystem interface,
allowing systems to manage their own queries locally or via other
means. Update all implementing systems and tests to use local
query descriptors instead.
2026-07-18 22:22:52 +08:00
hypercross ac7cd80a6f fix(oecs): clear dense entities array on reset 2026-07-18 22:06:33 +08:00
hypercross b6e64d2871 refactor: add struct constraint to Relationship
Add `where TSelf : struct where TTarget : struct` constraints to
the `Relationship` struct to ensure type safety and consistency with
the ECS architecture. Also remove unused `IterateThree` method in
`QueryExecutor`.
2026-07-18 22:05:29 +08:00
hypercross 58477aec50 docs: update API surface documentation 2026-07-18 21:59:09 +08:00
hypercross 32f4aa3e35 refactor: optimize query execution and improve change tracking
- Optimize `QueryExecutor` to use the smallest sparse set as a driver
  for multi-component queries, reducing iteration overhead.
- Implement automatic cleanup of unused `Subject` instances in
  `ChangeBuffer` using a subscriber count.
- Improve `ChangeSet` deduplication to correctly handle rapid
  add/remove transitions for the same component.
- Add validation to ensure `IRelationship` components are added to
  their declared `Source` entity.
- Update documentation to reflect correct `Entity` bit layout.
2026-07-18 21:42:08 +08:00
156 changed files with 9217 additions and 2064 deletions
+204
View File
@@ -0,0 +1,204 @@
---
name: developing-oecs
description: Develop and modify the OECS entity component system library itself. Use this when changing the core ECS types (World, Entity, ComponentStore, SparseSet, query execution, commands, relationships, reactivity, singletons, serialization) or the OECS.SourceGen incremental generator.
---
# Developing OECS
OECS is a single-threaded, observable-first ECS for C#. It targets `net8.0`
(C# 12). The source lives under `OECS/` and the incremental source generator is
under `OECS.SourceGen/`.
## Project Layout
```
OECS.sln
├── Directory.Build.props # Shared: net8.0, ImplicitUsings, Nullable, C# 12
├── OECS/
│ └── OECS.csproj # Core library + NuGet metadata
├── OECS.SourceGen/
│ └── OECS.SourceGen.csproj # Roslyn incremental source generator
├── OECS.Tests/ # Unit tests for OECS itself
├── Game.Blackjack/ # Integration test: blackjack game
├── Game.Blackjack.Tests/ # Blackjack tests (unit + snapshot + play)
├── Game.TicTacToe/ # Integration test: tic-tac-toe game
├── Game.TicTacToe.Tests/
├── docs/ # Architecture, API surface, implementation plan
└── nupkgs/ # Built NuGet packages
```
## Core Architecture
Read `docs/architecture.md` for the full rationale behind each design decision.
Key ADRs to keep in mind when changing the library:
| ADR | Decision | Why |
|---|---|---|
| 001 | Sparse sets over archetypes | Cheap add/remove for reactivity |
| 002 | 32-bit Entity (24 ID + 8 version) | Fits in register, enough headroom |
| 003 | Explicit query iteration | Visible cost model, no code-gen needed |
| 004 | Registration order for systems | Simplest model that works at this scale |
| 005 | Auto-tracking in batching scopes | Eliminates manual `MarkModified` in systems |
| 006 | Deferred mutation batching | Prevents mid-system reentrancy, safe iteration |
| 007 | R3 for reactivity | Zero-allocation, UI lifecycle-friendly |
| 008 | Commands as serializable structs in a queue | Not ECS state |
| 009 | Singleton per component type | Each singleton gets its own entity |
| 010 | Single-threaded by default | Simpler, no locks needed |
| 011 | .NET 8 target | LTS through Nov 2026 |
| 012 | Public types required for MessagePack | Build-time validation via analyzer |
## Key Types
### `World` — the public entry point
Everything routes through `World`. It owns:
- `EntityAllocator _allocator` — entity lifecycle (create, destroy, recycle).
- `ComponentStore _components` — generic sparse set registry.
- `RelationshipIndex _relationships` — reverse lookup for relationships.
- `ChangeBuffer _changes` — accumulates and posts changes to R3 subjects.
- `CommandQueue _commands` — deferred command execution.
### `ComponentStore` — sparse set registry
Maps `Type``SparseSet<T>`. Exposes `GetSet(Type)` for internal use.
Public API on `World` delegates here with generic type resolution.
### `SparseSet<T>` — per-type component storage
Dense/sparse array pair. `dense[]` is packed values, `denseEntities[]` is
parallel entity IDs, `sparse[]` maps entity ID → dense index (-1 = absent).
Uses swap-remove for O(1) deletion.
### `WorldQueryExtensions.Select1..Select6<T...>` — ref struct iterators
Zero-allocation `ref struct` enumerators returned by `world.Select<T1..T6>()`.
Drive from the smallest sparse set to minimize probes. Support `foreach` via
`GetEnumerator()` returning `this`. Expose `Entity`, `Ref1`..`RefN` (mutable,
tracked for auto-dirty-marking), and `Val1`..`ValN` (read-only, untracked).
Constructor/dispose manage `BeginBatching()`/`EndBatching()` for pending
mutation flushing. Singleton entities are excluded via `IsSingletonEntity()`.
Also provides `FindEntity<T>()` (first match).
### `Query<T1..T6>` — generic query descriptors
Structs that encode `With` types as generic parameters. `Without<W>()` fluent
method adds exclusion filters without changing the arity. Replaces the old
`QueryBuilder` / `QueryDescriptor` / `ForEachAction` pattern.
### `SystemGroup` — system orchestration
Manages an ordered list of `ISystem`. `RunAll()`:
1. Drain commands (pre-tick).
2. `BeginBatching()` — outer batching scope for the tick.
3. For each system: run via `Run` extension (nested batch) → drain commands →
flush pending mutations → post changes.
4. `EndBatching()` — auto-marks + flushes remaining.
5. Drain commands + post changes (post-tick).
### `WorldSerializer` — save/load
Uses the source-generated `ComponentRegistry` to discover component types
without reflection. Serializes each entity's components to MessagePack blobs.
On load, fixates `IRelationship.Source` to match the owning entity.
## Source Generator (`OECS.SourceGen`)
A Roslyn incremental source generator that:
1. Scans for all invocations of World methods with generic type arguments
(e.g., `AddComponent<Card>`, `SetSingleton<GameState>`).
2. Generates a `ComponentRegistry` class with `ComponentDescriptor[]` listing
every discovered type.
3. Each descriptor includes the type name, `Type` reference, and static lambdas
for serialization/deserialization via MessagePack.
The generator must be rebuilt before `OECS.csproj` packs into a NuGet package
(see the `BuildSourceGenerator` target in `OECS.csproj`).
## API Surface Contract
The public API surface documented in `docs/api-surface.md` is the contract.
Any change to a public type signature must update the doc. Check for drift
regularly — the doc should match the code exactly, not the other way around.
## Dependencies
| Package | Version | Purpose |
|---|---|---|
| `MessagePack` | 3.1.7 | Binary serialization for components, snapshots |
| `MessagePackAnalyzer` | 3.1.7 | Compile-time validation of `[MessagePackObject]` types |
| `R3` | 1.2.9 | Reactive observables for change subscriptions |
| `Microsoft.CodeAnalysis.CSharp` | via SourceGen | Roslyn incremental generator API |
## Building & Testing
```bash
# Build solution
dotnet build OECS.sln
# Run OECS core tests
dotnet test OECS.Tests/OECS.Tests.csproj
# Run blackjack integration tests
dotnet test Game.Blackjack.Tests/Game.Blackjack.Tests.csproj
# Pack NuGet
dotnet pack OECS/OECS.csproj -c Release -o nupkgs
```
## Adding a New Feature
1. Check ADRs in `docs/architecture.md` — does the change fit the existing
decisions? If it contradicts one, write a new ADR or revise the existing one.
2. Implement the feature in `OECS/`.
3. If it touches public API, update `docs/api-surface.md`.
4. Add tests in `OECS.Tests/`.
5. Update `docs/implementation-plan.md` if the phase descriptions need adjusting.
6. Verify the blackjack game still works (`dotnet test Game.Blackjack.Tests`).
## Changing the Source Generator
1. Modify `OECS.SourceGen/`.
2. Build it explicitly: `dotnet build OECS.SourceGen/OECS.SourceGen.csproj`.
3. The DLL at `OECS.SourceGen/bin/Debug/netstandard2.0/OECS.SourceGen.dll` is
referenced as an analyzer by downstream projects.
4. Test with a game project to verify component registry is generated correctly.
## Conventions
### Component and command types
Game-level components and commands should be `public record struct` types with
explicit fields/properties — **not** positional syntax. Positional record structs
produce `init`-only properties that can't be mutated through `ref T`, which
breaks the core OECS pattern of in-place component mutation.
```csharp
// ✅ Correct: explicit fields, ref-mutable
[MessagePackObject]
public record struct Card
{
[Key(0)] public Suit Suit;
[Key(1)] public Rank Rank;
}
// ❌ Wrong: positional syntax produces init-only properties
[MessagePackObject]
public record struct Card([Key(0)] Suit Suit, [Key(1)] Rank Rank);
```
Tag components (no data) can be plain `struct``record struct` adds no value
when there are no fields to compare.
## Common Pitfalls
- **Using `ValN` when you meant to mutate.** `ValN` returns a `ref readonly`
writes through it still work but won't be tracked for auto-dirty-marking.
Use `RefN` for any component you intend to mutate.
- **Modifying components during iteration.** Pending mutations are flushed at
the end of the batching scope. Adding/removing components mid-iteration is
safe — they're deferred.
- **Type not public or missing `[MessagePackObject]`.** Serialization will fail.
The `MessagePackAnalyzer` catches most issues at compile time.
- **Singletons each have their own entity allocated automatically by
`SetSingleton<T>`.** Iterators skip singleton entities automatically.
+195
View File
@@ -0,0 +1,195 @@
---
name: testing-games
description: Test OECS-based games via unit tests, snapshots, playtests with AI agents, reactivity logs, and serialization round-trips. Use this when writing or running tests for a game built on OECS.
---
# Testing Games with OECS
Read `docs/testing-games.md` for the testing strategy overview. This skill
provides the detailed patterns and conventions.
## Test Project Setup
A test project references the game DLL plus xUnit and FluentAssertions:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Game.YourGame.Tests</RootNamespace>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Game.YourGame\YourGame.csproj" />
</ItemGroup>
</Project>
```
## Three Test Categories
### 1. Unit / Game Flow Tests
Test individual game rules in isolation. Each test sets up a fresh `World`,
enqueues commands, runs a tick, and asserts state:
```csharp
[Fact]
public void PlaceBet_AdvancesToDealing()
{
var (world, group) = SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
var state = world.ReadSingleton<GameState>();
state.Phase.Should().Be(GamePhase.PlayerTurn);
state.Chips.Should().Be(90);
}
```
Common test helpers:
- `SetupGame(seed?)` — create world, register systems, set initial singletons.
- `FindEntity<T>(world)` — find first entity with component T (singletons are automatically excluded by query results).
- `CountCardsInHand(world, handEntity)` or `GetHandCards(world, handEntity)`.
Always keep helpers in the test class (or a shared base) rather than in the
game DLL — they are test infrastructure.
### 2. Snapshot & Log Tests
Capture world state as text and R3 change logs for manual review:
```csharp
[Fact]
public void Snapshot_AfterDeal()
{
var (world, group) = SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
var snapshot = SnapshotWorld(world);
_output.WriteLine(snapshot); // ITestOutputHelper
snapshot.Should().Contain("Phase: PlayerTurn");
snapshot.Should().Contain("Player hand:");
snapshot.Should().Contain("Dealer hand:");
}
```
`SnapshotWorld(world)` should produce a human-readable text block containing
all singletons, entity counts, hand contents, and any other debug-relevant state.
Reactivity log test pattern:
```csharp
var log = new List<string>();
world.ObserveComponentChanges<Card>().Subscribe(change =>
log.Add($"[card] {change}"));
world.ObserveComponentChanges<GameState>().Subscribe(change =>
log.Add($"[gamestate] {change}"));
// ... run game ...
log.Should().Contain(l => l.Contains("EntityAdded"));
log.Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState"));
```
### 3. Play Tests
AI-driven multi-round playtests. See `docs/testing-games.md` for the full
strategy. Key patterns:
**Agents** implement a simple interface:
```csharp
private interface IYourGameAgent
{
Decision Decide(World world);
}
```
Built-in agent types:
- **Greedy/Basic Strategy:** score actions, pick the highest. For blackjack:
hit if total < 17, stand otherwise.
- **Random:** evenly choose moves randomly (but never do illegal moves).
- **Weighted Pool:** pick from a pool of agents by weight each round.
**Round runner** loops until a terminal condition:
```csharp
while (true)
{
int chips = world.ReadSingleton<GameState>().Chips;
if (chips < BetAmount) { /* busted */ break; }
if (chips >= StartingChips * 2) { /* doubled */ break; }
if (totalRounds >= 200) { /* safety cap */ break; }
// Place bet, deal, agent decides hit/stand, resolve, new round.
}
```
**Play logs** are saved as `.playlog` files to `AppContext.BaseDirectory/playlogs/`:
```
Blackjack: Basic Strategy (seed=42, agent=BasicStrategy) — BUSTED after 38 rounds | W:12 L:22 P:4
=================================================================================================
--- Round Summaries ---
Round 1: PlayerBust | Bet=10 | Chips: 100→90 (-10) | Hits: 1
...
--- Decisions ---
1. R1 Hand=12, Decision=Hit
...
--- Reactivity ---
ComponentModified GameState = ...
...
--- Final State ---
Phase: Betting
Chips: 0, Bet: 10
...
```
## Serialization Round-Trip
Every game must have a serialization test:
```csharp
[Fact]
public void Serialization_RoundTrips()
{
var (world, group) = SetupGame(seed: 42);
// ... run some game state ...
using var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
// Assert key state survived:
var state = world2.ReadSingleton<GameState>();
state.Chips.Should().Be(expected);
}
```
## Running Tests
```bash
dotnet test "E:/projects/oecs-sharp/Game.YourGame.Tests/Game.YourGame.Tests.csproj"
```
To run only playtests:
```bash
dotnet test ... --filter "FullyQualifiedName~PlayTests"
```
+302
View File
@@ -0,0 +1,302 @@
---
name: writing-games
description: Create game logic using the OECS entity component system (C#). Use this when building a new game or game feature with OECS — defining components, systems, commands, relationships, and singletons.
---
# Writing Games with OECS
OECS is a single-threaded, observable-first ECS for C#. It targets `net8.0` (C# 12)
and depends on `MessagePack` (serialization) and `R3` (reactivity).
Before writing any code, read `docs/api-surface.md` for the full type reference
and `docs/architecture.md` for the design rationale behind the key decisions.
## Project Setup
A game is a class library referencing `OECS`:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<RootNamespace>Game.YourGameName</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OECS\OECS.csproj" />
</ItemGroup>
</Project>
```
The game DLL must also reference `OECS.SourceGen` as an analyzer so the
component registry is generated for serialization. See `Blackjack.csproj` for
the exact MSBuild incantation.
## Defining Components
Components are `public record struct` types annotated with `[MessagePackObject]`
and `[Key]` attributes. Prefer `record struct` by default — it gives you value
equality and a generated `ToString()` for free.
Use **explicit properties or fields** — not positional syntax. OECS mutates
components in-place via `ref T`, which requires settable fields/properties.
Positional record structs produce `init`-only properties that can't be mutated
through a `ref`.
```csharp
[MessagePackObject]
public record struct Card
{
[Key(0)] public Suit Suit;
[Key(1)] public Rank Rank;
}
```
- Types must be `public` — MessagePack requires public accessibility.
- Use sequential integer keys starting from 0.
- Tag components (no data) are just empty structs:
```csharp
[MessagePackObject]
public struct PlayerHand { }
```
## Relationships
Relationships are components that implement `IRelationship`. They model a
directed edge between a source entity and a target entity.
You can either use the generic `Relationship<TSelf, TTarget>` base struct or
implement `IRelationship` directly:
```csharp
// Using the base struct:
world.AddComponent(child, new Relationship<ChildOf, Parent>
{
Source = child,
Target = parent
});
// Direct implementation (preferred for domain-specific names):
[MessagePackObject]
public record struct Holds : IRelationship
{
[Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; }
}
```
Reverse lookup is automatic. The `World` maintains a reverse index so you can
query all sources pointing to a target:
```csharp
var cards = world.GetSources<Holds>(handEntity);
```
When an entity is destroyed, all relationships it participates in (as source or
target) are cleaned up automatically.
## Defining Systems
Systems implement `ISystem` (or `ITickedSystem` if they need delta time).
Implement `RunImpl` with the system logic:
```csharp
public class DealSystem : ISystem
{
public void RunImpl(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Dealing)
return;
// Do work...
}
}
```
The `ISystem` interface has no `Query` property. Systems read singletons,
build queries, and iterate on their own — this keeps the interface minimal
and gives systems full flexibility.
### Running Systems
Use the `Run` extension method to execute a system with automatic batching:
```csharp
var system = new MySystem();
system.Run(world); // wraps RunImpl in BeginBatching/EndBatching
```
`SystemGroup` handles this automatically — you just register systems and call
`RunLogical()` or `RunTimed()`.
### Iteration
Use `world.Select<T1..T6>()` with `foreach` for zero-allocation iteration with
`ref` access to components. Use `RefN` for components you intend to mutate
(tracked for auto-dirty-marking) and `ValN` for read-only access (untracked):
```csharp
foreach (var it in world.Select<Position, Velocity>())
{
it.Ref1.X += it.Val2.X * dt; // Position tracked, Velocity not
it.Ref1.Y += it.Val2.Y * dt;
}
```
For queries with exclusion filters, create a `Query<T>` with `.Without<W>()`:
```csharp
var query = new Query<Cell>().Without<Mark>();
foreach (var it in world.Select(query))
{
if (it.Val1.Row == row && it.Val1.Col == col) { ... }
}
```
To find a single entity, use `FindEntity<T>()`:
```csharp
var handEntity = world.FindEntity<PlayerHand>();
```
The `Select` ref struct enumerators expose:
- `it.Entity` — the current entity handle.
- `it.Ref1`..`it.RefN``ref` references to components (tracked for auto-dirty).
- `it.Val1`..`it.ValN``ref readonly` references to components (untracked).
Singleton entities are automatically excluded from query results.
### System Registration
Systems run in registration order via `SystemGroup`:
```csharp
var world = new World();
var group = new SystemGroup(world);
group.Add(new DeckSetupSystem());
group.Add(new DealSystem());
group.Add(new PlayerBustCheckSystem());
group.Add(new DealerSystem());
```
`SystemGroup` automatically drains commands, flushes pending mutations, and
posts changes after each system and after the full tick.
## Defining Commands
Commands are `public record struct` types annotated with `[MessagePackObject]`
and implementing `ICommand`:
```csharp
[MessagePackObject]
public record struct PlaceBetCommand : ICommand
{
[Key(0)] public int Amount;
public void Execute(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Phase != GamePhase.Betting) return;
state.CurrentBet = Amount;
state.Chips -= Amount;
state.Phase = GamePhase.Dealing;
}
}
```
Enqueue commands via `world.Commands.Enqueue(...)`. They execute deferred
when the queue is drained (automatically by `SystemGroup`). Commands run
inside a batching scope, so `GetSingleton` and `GetComponent` calls are
auto-tracked for modification.
## Singletons
Each singleton component type gets its own dedicated entity, allocated
automatically by `SetSingleton<T>`. Use `SetSingleton<T>`, `GetSingleton<T>`
(ref), and `ReadSingleton<T>` (copy):
```csharp
// Setup:
world.SetSingleton(new GameState { Phase = GamePhase.Betting, Chips = 100 });
// Read-only inspection:
var state = world.ReadSingleton<GameState>();
// Mutation (auto-tracked during batching scopes):
ref var mutable = ref world.GetSingleton<GameState>();
mutable.Phase = GamePhase.RoundOver;
```
`GetSingleton` returns a `ref`. During system execution, command drains, and
`foreach` iterations, mutations via `GetSingleton` are auto-marked for change
tracking. Outside a batching scope, call `MarkModified` after mutating.
`ReadSingleton` returns a copy and never auto-marks.
## Change Tracking
- Structural changes (entity create/destroy, component add/remove) are always
auto-marked.
- Value mutations via `GetComponent<T>`, `GetSingleton<T>`, and `RefN` iterator
properties are auto-tracked during batching scopes (system runs, command
drains, `foreach` iterations).
- Outside batching scopes, call `world.MarkModified<T>(entity)` explicitly.
- Changes are posted after each system runs (automatic via `SystemGroup`).
## Serialization
`WorldSerializer.Save/Load` uses the source-generated `ComponentRegistry`.
All component types used with `World` generic methods are automatically
discovered. Serialization round-trips must be tested — see `testing-games` skill.
## Interrupts
Interrupts pause system execution until an external response arrives. A system
issues an interrupt, the next tick is skipped, and a handler command resolves it.
### Issuing an Interrupt
```csharp
[MessagePackObject]
public record struct ConfirmInterrupt : IInterrupt
{
[Key(0)] public string Message;
}
// In a system:
world.Interrupt(new ConfirmInterrupt { Message = "Are you sure?" });
```
The current tick completes normally. The **next** tick is blocked — `SystemGroup`
skips all systems but still drains commands and posts changes.
### Handling an Interrupt
Handler commands implement `IInterruptHandlerCommand<T>`. The default `Execute`
calls `TryResolve` with the pending interrupt. Return `true` to resolve it,
`false` to leave it pending for another handler:
```csharp
[MessagePackObject]
public struct ConfirmHandler : IInterruptHandlerCommand<ConfirmInterrupt>
{
[Key(0)] public bool Confirmed;
public bool TryResolve(ConfirmInterrupt interrupt)
{
// Do work here. The interrupt is just a signal.
return true;
}
}
// External code (e.g., UI button) enqueues the handler:
world.Commands.Enqueue(new ConfirmHandler { Confirmed = true });
```
### Key Rules
- Only one interrupt of a given type may be pending at a time. A second call
to `Interrupt<T>` with the same type throws.
- Interrupts are transient — they are not serialized and do not survive save/load.
- If no `SystemGroup` manages the world, `Interrupt<T>()` is a no-op.
- Check pending interrupts with `world.HasInterrupt<T>()`.
+1
View File
@@ -15,6 +15,7 @@ x86/
!**/[Pp]ackages/build/ !**/[Pp]ackages/build/
*.nuget.props *.nuget.props
*.nuget.targets *.nuget.targets
nupkgs/
# IDE # IDE
.vs/ .vs/
+9
View File
@@ -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>
+21
View File
@@ -0,0 +1,21 @@
<!--
Directory.Build.props for Godot C# projects consuming OECS.
Godot regenerates .csproj files on certain editor actions, which can
wipe custom <ItemGroup> entries. This file is never touched by Godot.
Placement: put this file in the root of your Godot project (next to
the .sln file), or in any parent directory.
You also need a nuget.config next to your .sln with the local feed:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="local-oecs" value="path/to/oecs-sharp/nupkgs" />
</packageSources>
</configuration>
-->
<Project>
<ItemGroup>
<PackageReference Include="OECS" Version="0.1.*" />
</ItemGroup>
</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>
+195
View File
@@ -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);
}
}
+199
View File
@@ -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";
}
}
+146
View File
@@ -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");
}
}
+131
View File
@@ -0,0 +1,131 @@
using Game.Blackjack;
using OECS;
using System.Text;
namespace Game.Blackjack.Tests;
internal static class TestHelpers
{
private const int StartingChips = 100;
/// <summary>
/// Creates a fresh Blackjack world with systems registered and GameState singleton initialized.
/// </summary>
public static (World World, SystemGroup Group) SetupGame(uint seed = 42)
{
var world = new World();
var group = new SystemGroup(world);
group.Add(new DeckSetupSystem());
group.Add(new DealSystem());
group.Add(new PlayerBustCheckSystem());
group.Add(new DealerSystem());
world.SetSingleton(new GameState
{
Phase = GamePhase.Betting,
Result = RoundResult.None,
Chips = StartingChips,
CurrentBet = 0,
RoundNumber = 1,
Seed = seed
});
world.PostChanges();
return (world, group);
}
/// <summary>
/// Returns a human-readable textual snapshot of the world state.
/// </summary>
public static string SnapshotWorld(World world)
{
var sb = new StringBuilder();
var state = world.ReadSingleton<GameState>();
sb.AppendLine($"Phase: {state.Phase}");
sb.AppendLine($"Chips: {state.Chips}");
sb.AppendLine($"Bet: {state.CurrentBet}");
sb.AppendLine($"Round: {state.RoundNumber}");
sb.AppendLine($"Result: {state.Result}");
sb.AppendLine($"Seed: {state.Seed}");
var deckEntity = FindEntity<Deck>(world);
if (deckEntity != Entity.Null)
{
var cardsInDeck = world.GetSources<InDeck>(deckEntity).ToList();
sb.AppendLine($"Cards in deck: {cardsInDeck.Count}");
}
var playerHand = FindEntity<PlayerHand>(world);
if (playerHand != Entity.Null)
{
var cards = world.GetSources<Holds>(playerHand).ToList();
sb.AppendLine($"Player hand: {cards.Count} cards");
foreach (var cardEntity in cards)
{
var card = world.ReadComponent<Card>(cardEntity);
sb.AppendLine($" {card.Rank} of {card.Suit}");
}
}
var dealerHand = FindEntity<DealerHand>(world);
if (dealerHand != Entity.Null)
{
var cards = world.GetSources<Holds>(dealerHand).ToList();
sb.AppendLine($"Dealer hand: {cards.Count} cards");
foreach (var cardEntity in cards)
{
var card = world.ReadComponent<Card>(cardEntity);
sb.AppendLine($" {card.Rank} of {card.Suit}");
}
}
int entityCount = 0;
using (var iter = world.Select<Card>())
{
while (iter.MoveNext()) entityCount++;
}
sb.AppendLine($"Card entities: {entityCount}");
return sb.ToString().TrimEnd();
}
public static Entity FindEntity<T>(World world) where T : struct
{
return world.FindEntity<T>();
}
public static int CountCardsInHand(World world, Entity handEntity)
{
return world.GetSources<Holds>(handEntity).Count;
}
public static List<string> GetHandCards(World world, Entity hand)
{
var cards = new List<string>();
foreach (var cardEntity in world.GetSources<Holds>(hand))
{
var card = world.ReadComponent<Card>(cardEntity);
cards.Add($"{card.Rank} of {card.Suit}");
}
return cards;
}
public static List<(Suit, Rank)> GetAllCards(World world)
{
var cards = new List<(Suit, Rank)>();
using (var iter = world.Select<Card>())
{
while (iter.MoveNext())
{
var card = iter.Val1;
cards.Add((card.Suit, card.Rank));
}
}
cards.Sort((a, b) =>
{
int cmp = a.Item1.CompareTo(b.Item1);
return cmp != 0 ? cmp : a.Item2.CompareTo(b.Item2);
});
return cards;
}
}
+23
View File
@@ -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>
+54
View File
@@ -0,0 +1,54 @@
using MessagePack;
using OECS;
namespace Game.Blackjack;
/// <summary>
/// Player hits: draws one card from the deck to the player's hand.
/// </summary>
[MessagePackObject]
public struct HitCommand : ICommand
{
public void Execute(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.PlayerTurn)
return;
DrawCard<PlayerHand>(world);
}
/// <summary>
/// Draws the top card from the deck and adds it to the given hand.
/// </summary>
internal static void DrawCard<THand>(World world)
where THand : struct
{
var deckEntity = world.FindEntity<Deck>();
var handEntity = world.FindEntity<THand>();
if (deckEntity == Entity.Null || handEntity == Entity.Null)
return;
// Find a card still in the deck.
var cardsInDeck = world.GetSources<InDeck>(deckEntity);
if (cardsInDeck.Count == 0)
return;
var cardEntity = cardsInDeck.First();
// Remove from deck, add to hand.
world.RemoveComponent<InDeck>(cardEntity);
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)
where T : struct
{
return world.FindEntity<T>();
}
}
@@ -0,0 +1,50 @@
using MessagePack;
using OECS;
namespace Game.Blackjack;
/// <summary>
/// Starts a new round after the previous one ended.
/// </summary>
[MessagePackObject]
public struct NewRoundCommand : ICommand
{
public void Execute(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Phase != GamePhase.RoundOver)
return;
// Clear hands from previous round.
var handEntities = new List<Entity>();
using (var iter = world.Select<PlayerHand>())
{
while (iter.MoveNext())
{
handEntities.Add(iter.Entity);
}
}
using (var iter = world.Select<DealerHand>())
{
while (iter.MoveNext())
{
handEntities.Add(iter.Entity);
}
}
foreach (var hand in handEntities)
{
var cards = world.GetSources<Holds>(hand);
var deckEntity = world.FindEntity<Deck>();
foreach (var card in cards)
{
world.RemoveComponent<Holds>(card);
world.AddComponent(card, new InDeck { Target = deckEntity });
}
}
state.RoundNumber++;
state.Phase = GamePhase.Betting;
state.Result = RoundResult.None;
}
}
@@ -0,0 +1,28 @@
using MessagePack;
using OECS;
namespace Game.Blackjack;
/// <summary>
/// Places a bet and advances the game to the dealing phase.
/// </summary>
[MessagePackObject]
public 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;
if (Amount < 1 || Amount > state.Chips)
return;
state.CurrentBet = Amount;
state.Chips -= Amount;
state.Phase = GamePhase.Dealing;
}
}
+21
View File
@@ -0,0 +1,21 @@
using MessagePack;
using OECS;
namespace Game.Blackjack;
/// <summary>
/// Player stands: advance to the dealer's turn.
/// </summary>
[MessagePackObject]
public struct StandCommand : ICommand
{
public void Execute(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Phase != GamePhase.PlayerTurn)
return;
state.Phase = GamePhase.DealerTurn;
}
}
+14
View File
@@ -0,0 +1,14 @@
using MessagePack;
namespace Game.Blackjack;
/// <summary>
/// A playing card with a suit and rank.
/// One entity per card.
/// </summary>
[MessagePackObject]
public record struct Card
{
[Key(0)] public Suit Suit;
[Key(1)] public Rank Rank;
}
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Game.Blackjack;
/// <summary>
/// Tag component marking the dealer's hand entity.
/// </summary>
[MessagePackObject]
public struct DealerHand { }
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Game.Blackjack;
/// <summary>
/// Tag component marking the deck entity.
/// </summary>
[MessagePackObject]
public struct Deck { }
+14
View File
@@ -0,0 +1,14 @@
using MessagePack;
using OECS;
namespace Game.Blackjack;
/// <summary>
/// Relationship from a hand entity to a card entity,
/// representing that the hand holds this card.
/// </summary>
[MessagePackObject]
public record struct Holds : IRelationship
{
[Key(0)] public Entity Target { get; set; }
}
+14
View File
@@ -0,0 +1,14 @@
using MessagePack;
using OECS;
namespace Game.Blackjack;
/// <summary>
/// Relationship from a card entity to the deck entity,
/// representing that the card is in the deck.
/// </summary>
[MessagePackObject]
public record struct InDeck : IRelationship
{
[Key(0)] public Entity Target { get; set; }
}
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Game.Blackjack;
/// <summary>
/// Tag component marking the player's hand entity.
/// </summary>
[MessagePackObject]
public struct PlayerHand { }
+18
View File
@@ -0,0 +1,18 @@
namespace Game.Blackjack;
public enum Rank : byte
{
Ace = 1,
Two = 2,
Three = 3,
Four = 4,
Five = 5,
Six = 6,
Seven = 7,
Eight = 8,
Nine = 9,
Ten = 10,
Jack = 11,
Queen = 12,
King = 13
}
+9
View File
@@ -0,0 +1,9 @@
namespace Game.Blackjack;
public enum Suit : byte
{
Clubs = 0,
Diamonds = 1,
Hearts = 2,
Spades = 3
}
+28
View File
@@ -0,0 +1,28 @@
namespace Game.Blackjack;
/// <summary>
/// Mulberry32 PRNG — a fast, high-quality 32-bit random number generator.
/// Seed is stored in the ECS GameState singleton.
/// </summary>
public static class Mulberry32
{
/// <summary>
/// Advances the state and returns a random float in [0, 1).
/// </summary>
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;
}
/// <summary>
/// Advances the state and returns a random integer in [min, max].
/// </summary>
public static int NextInt(ref uint state, int min, int max)
{
return (int)(NextFloat(ref state) * (max - min + 1)) + min;
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace Game.Blackjack;
public enum GamePhase : byte
{
Betting = 0,
Dealing = 1,
PlayerTurn = 2,
DealerTurn = 3,
RoundOver = 4
}
+17
View File
@@ -0,0 +1,17 @@
using MessagePack;
namespace Game.Blackjack;
/// <summary>
/// Global game state stored on the singleton entity.
/// </summary>
[MessagePackObject]
public record struct GameState
{
[Key(0)] public GamePhase Phase;
[Key(1)] public RoundResult Result;
[Key(2)] public int Chips;
[Key(3)] public int CurrentBet;
[Key(4)] public int RoundNumber;
[Key(5)] public uint Seed;
}
+12
View File
@@ -0,0 +1,12 @@
namespace Game.Blackjack;
public enum RoundResult : byte
{
None = 0,
PlayerBust = 1,
DealerBust = 2,
PlayerWin = 3,
DealerWin = 4,
Push = 5,
Blackjack = 6
}
+30
View File
@@ -0,0 +1,30 @@
using OECS;
namespace Game.Blackjack;
/// <summary>
/// Deals initial two cards to player and dealer when entering the dealing phase.
/// Advances to player turn after dealing.
/// </summary>
public class DealSystem : ISystem
{
public void RunImpl(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Dealing)
return;
ref var mutableState = ref world.GetSingleton<GameState>();
// Deal two cards to player, then two to dealer.
HitCommand.DrawCard<PlayerHand>(world);
HitCommand.DrawCard<PlayerHand>(world);
HitCommand.DrawCard<DealerHand>(world);
HitCommand.DrawCard<DealerHand>(world);
mutableState.Phase = GamePhase.PlayerTurn;
}
}
internal static class PlayerHandTag { public static readonly PlayerHand Instance = new(); }
internal static class DealerHandTag { public static readonly DealerHand Instance = new(); }
+55
View File
@@ -0,0 +1,55 @@
using OECS;
namespace Game.Blackjack;
/// <summary>
/// Dealer draws cards until reaching 17 or higher,
/// then resolves the round result.
/// </summary>
public class DealerSystem : ISystem
{
public void RunImpl(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.DealerTurn)
return;
ref var mutableState = ref world.GetSingleton<GameState>();
// Dealer must hit on 16 and below, stand on 17+.
int dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
int safety = 0;
while (dealerTotal < 17 && safety < 52)
{
HitCommand.DrawCard<DealerHand>(world);
dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
safety++;
}
int playerTotal = HandUtil.CalculateHand(world, PlayerHandTag.Instance);
mutableState.Phase = GamePhase.RoundOver;
if (dealerTotal > 21)
{
mutableState.Result = RoundResult.DealerBust;
mutableState.Chips += mutableState.CurrentBet * 2;
}
else if (playerTotal > dealerTotal)
{
mutableState.Result = RoundResult.PlayerWin;
mutableState.Chips += mutableState.CurrentBet * 2;
}
else if (dealerTotal > playerTotal)
{
mutableState.Result = RoundResult.DealerWin;
}
else
{
// Push: return the bet.
mutableState.Result = RoundResult.Push;
mutableState.Chips += mutableState.CurrentBet;
}
}
}
+69
View File
@@ -0,0 +1,69 @@
using OECS;
namespace Game.Blackjack;
/// <summary>
/// Creates the deck (52 cards + deck/hand entities) and shuffles
/// using mulberry32 with the seed from the GameState singleton.
/// Runs once at the start of each round (during Dealing phase).
/// </summary>
public class DeckSetupSystem : ISystem
{
public void RunImpl(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Dealing)
return;
// Only create deck entities if they don't exist yet.
bool hasDeck = false;
using (var iter = world.Select<Deck>())
{
hasDeck = iter.MoveNext();
}
if (!hasDeck)
{
// Create deck entity.
var deckEntity = world.CreateEntity();
world.AddComponent(deckEntity, new Deck());
// Create all 52 cards.
foreach (Suit suit in Enum.GetValues<Suit>())
{
foreach (Rank rank in Enum.GetValues<Rank>())
{
var cardEntity = world.CreateEntity();
world.AddComponent(cardEntity, new Card { Suit = suit, Rank = rank });
world.AddComponent(cardEntity, new InDeck { Target = deckEntity });
}
}
// Create hand entities.
var playerHand = world.CreateEntity();
world.AddComponent(playerHand, new PlayerHand());
var dealerHand = world.CreateEntity();
world.AddComponent(dealerHand, new DealerHand());
}
// Shuffle the deck using mulberry32 with the current seed.
ref var mutableState = ref world.GetSingleton<GameState>();
var deckEntity2 = world.FindEntity<Deck>();
var cards = world.GetSources<InDeck>(deckEntity2).ToArray();
Shuffle(world, deckEntity2, cards, ref mutableState.Seed);
}
private static void Shuffle(World world, Entity deckEntity, Entity[] cardEntities, ref uint seed)
{
// Fisher-Yates shuffle using mulberry32.
for (int i = cardEntities.Length - 1; i > 0; i--)
{
int j = Mulberry32.NextInt(ref seed, 0, i);
(cardEntities[i], cardEntities[j]) = (cardEntities[j], cardEntities[i]);
}
// Reorder the source set in-place — no component add/remove needed.
world.ReorderSources<InDeck>(deckEntity, cardEntities);
}
}
+61
View File
@@ -0,0 +1,61 @@
using OECS;
namespace Game.Blackjack;
/// <summary>
/// Helper to calculate the blackjack value of a hand.
/// Aces count as 11 unless that would bust, then they count as 1.
/// </summary>
public static class HandUtil
{
public static int CalculateHand(World world, DealerHand hand)
{
return CalculateHandImpl(world, hand);
}
public static int CalculateHand(World world, PlayerHand hand)
{
return CalculateHandImpl(world, hand);
}
private static int CalculateHandImpl<T>(World world, T handTag)
where T : struct
{
// Find the hand entity.
var handEntity = world.FindEntity<T>();
if (handEntity == Entity.Null)
return 0;
var cards = world.GetSources<Holds>(handEntity);
int total = 0;
int aceCount = 0;
foreach (var cardEntity in cards)
{
var card = world.ReadComponent<Card>(cardEntity);
int value = card.Rank switch
{
Rank.Ace => 11,
Rank.Jack => 10,
Rank.Queen => 10,
Rank.King => 10,
_ => (int)card.Rank
};
if (card.Rank == Rank.Ace)
aceCount++;
total += value;
}
// Downgrade aces from 11 to 1 as needed.
while (total > 21 && aceCount > 0)
{
total -= 10;
aceCount--;
}
return total;
}
}
@@ -0,0 +1,25 @@
using OECS;
namespace Game.Blackjack;
/// <summary>
/// Evaluates the player's hand total after each hit.
/// Busts the player if they exceed 21.
/// </summary>
public class PlayerBustCheckSystem : ISystem
{
public void RunImpl(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.PlayerTurn)
return;
var total = HandUtil.CalculateHand(world, PlayerHandTag.Instance);
if (total <= 21)
return;
ref var mutableState = ref world.GetSingleton<GameState>();
mutableState.Phase = GamePhase.RoundOver;
mutableState.Result = RoundResult.PlayerBust;
}
}
+139
View File
@@ -0,0 +1,139 @@
using FluentAssertions;
using Game.CardWars;
using OECS;
using Xunit;
namespace Game.CardWars.Tests;
public class CardEffectTests
{
private static (World World, SystemGroup Group) Setup()
{
CardEffectRegistry.ClearForTests();
return GameFactory.Create(playerCount: 2, seed: 42);
}
[Fact]
public void WarriorEffect_AddsHornWhenAnotherWarriorExists()
{
var (world, group) = Setup();
var state = world.ReadSingleton<GameState>();
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
// Play two warriors.
var hand = GameUtil.GetHandCards(world, player);
var warrior1 = FindCardWithEffect(world, hand, CardEffect.Warrior);
var warrior2 = FindSecondCardWithEffect(world, hand, CardEffect.Warrior);
if (warrior1 == Entity.Null || warrior2 == Entity.Null) return; // Not enough warriors in deck.
var def = world.ReadComponent<CardDef>(warrior1);
world.Commands.Enqueue(new PlayCardCommand
{
CardEntity = warrior1,
Rank = def.Ranks[0],
FaceDown = false,
TargetPlayer = null
});
group.RunLogical();
// First warrior: no other warrior, no horn.
GameUtil.GetFieldCards(world, player).Should().Contain(warrior1);
world.HasComponent<Horn>(warrior1).Should().BeFalse(because: "no other warrior on field");
// Play second warrior.
var def2 = world.ReadComponent<CardDef>(warrior2);
world.Commands.Enqueue(new PlayCardCommand
{
CardEntity = warrior2,
Rank = def2.Ranks[0],
FaceDown = false,
TargetPlayer = null
});
group.RunLogical();
// Second warrior has another warrior, should get horn.
// But Wait — the effect checks "other" warriors on field. The first warrior
// is on field, so the second warrior should get a horn.
world.HasComponent<Horn>(warrior2).Should().BeTrue(because: "another warrior exists on field");
}
[Fact]
public void MercenaryEffect_SetsPendingChoice()
{
var (world, group) = Setup();
var state = world.ReadSingleton<GameState>();
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
// Play first mercenary (no effect).
var hand = GameUtil.GetHandCards(world, player);
var merc = FindCardWithEffect(world, hand, CardEffect.Mercenary);
if (merc == Entity.Null) return;
var def = world.ReadComponent<CardDef>(merc);
world.Commands.Enqueue(new PlayCardCommand
{
CardEntity = merc,
Rank = def.Ranks[0],
FaceDown = false,
TargetPlayer = null
});
group.RunLogical();
// Play second mercenary. We need another one from a drawn card.
var publicDeck = GameUtil.FindEntity<PublicDeck>(world);
GameUtil.DrawCard(world, publicDeck, player);
var newHand = GameUtil.GetHandCards(world, player);
var merc2 = FindCardWithEffect(world, newHand, CardEffect.Mercenary);
if (merc2 == Entity.Null) return;
var def2 = world.ReadComponent<CardDef>(merc2);
world.Commands.Enqueue(new PlayCardCommand
{
CardEntity = merc2,
Rank = def2.Ranks[0],
FaceDown = false,
TargetPlayer = null
});
group.RunLogical();
// Should have a pending Mercenary choice.
world.HasSingleton<PendingMercenary>().Should().BeTrue();
var pending = world.ReadSingleton<PendingMercenary>();
pending.CardEntity.Should().Be(merc2);
// Resolve the choice (pick a target).
world.Commands.Enqueue(new ResolveChoiceCommand { Target = merc });
group.RunLogical();
// The pending should be cleared.
world.HasSingleton<PendingMercenary>().Should().BeFalse();
}
private static Entity FindCardWithEffect(World world, List<Entity> cards, CardEffect effect)
{
foreach (var c in cards)
{
if (world.HasComponent<CardDef>(c) && world.ReadComponent<CardDef>(c).Effect == effect)
return c;
}
return Entity.Null;
}
private static Entity FindSecondCardWithEffect(World world, List<Entity> cards, CardEffect effect)
{
Entity first = Entity.Null;
foreach (var c in cards)
{
if (world.HasComponent<CardDef>(c) && world.ReadComponent<CardDef>(c).Effect == effect)
{
if (first == Entity.Null) first = c;
else return c;
}
}
return Entity.Null;
}
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Game.CardWars.Tests</RootNamespace>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Game.CardWars\CardWars.csproj" />
</ItemGroup>
</Project>
+148
View File
@@ -0,0 +1,148 @@
using FluentAssertions;
using Game.CardWars;
using OECS;
using Xunit;
namespace Game.CardWars.Tests;
public class GameSetupTests
{
private static (World World, SystemGroup Group) Setup()
{
// Reset registry state between tests.
CardEffectRegistry.ClearForTests();
return GameFactory.Create(playerCount: 2, seed: 42);
}
[Fact]
public void CreateGame_CreatesPlayersAndDecks()
{
var (world, _) = Setup();
var state = world.ReadSingleton<GameState>();
state.Phase.Should().Be(GamePhase.PlayPhase);
state.PlayerCount.Should().Be(2);
state.RoundNumber.Should().Be(1);
GameUtil.FindEntity<PublicDeck>(world).Should().NotBe(Entity.Null);
GameUtil.FindAllEntities<Player>(world).Should().HaveCount(2);
GameUtil.FindAllEntities<Leader>(world).Should().HaveCount(2);
GameUtil.FindAllEntities<Banner>(world).Should().HaveCount(2);
GameUtil.FindAllEntities<Castle>(world).Should().NotBeEmpty();
}
[Fact]
public void StartingHand_HasThreeCards()
{
var (world, _) = Setup();
foreach (var player in GameUtil.FindAllEntities<Player>(world))
{
var hand = GameUtil.GetHandCards(world, player);
hand.Should().HaveCount(3);
}
}
[Fact]
public void PlayCard_MovesCardFromHandToField()
{
var (world, group) = Setup();
var state = world.ReadSingleton<GameState>();
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
var hand = GameUtil.GetHandCards(world, player);
var cardInHand = hand[0];
var def = world.ReadComponent<CardDef>(cardInHand);
world.Commands.Enqueue(new PlayCardCommand
{
CardEntity = cardInHand,
Rank = def.Ranks[0],
FaceDown = false,
TargetPlayer = null
});
group.RunLogical();
GameUtil.GetFieldCards(world, player).Should().Contain(cardInHand);
var cardData = world.ReadComponent<Card>(cardInHand);
cardData.Rank.Should().Be(def.Ranks[0]);
cardData.FaceDown.Should().BeFalse();
}
[Fact]
public void PlayCardFaceDown_CardIsHidden()
{
var (world, group) = Setup();
var state = world.ReadSingleton<GameState>();
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
var hand = GameUtil.GetHandCards(world, player);
var cardInHand = hand[0];
var def = world.ReadComponent<CardDef>(cardInHand);
world.Commands.Enqueue(new PlayCardCommand
{
CardEntity = cardInHand,
Rank = def.Ranks[0],
FaceDown = true,
TargetPlayer = null
});
group.RunLogical();
world.ReadComponent<Card>(cardInHand).FaceDown.Should().BeTrue();
}
[Fact]
public void FlipCard_RevealsFaceDownCard()
{
var (world, group) = Setup();
var state = world.ReadSingleton<GameState>();
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
var hand = GameUtil.GetHandCards(world, player);
var cardInHand = hand[0];
var def = world.ReadComponent<CardDef>(cardInHand);
// Play face-down.
world.Commands.Enqueue(new PlayCardCommand
{
CardEntity = cardInHand,
Rank = def.Ranks[0],
FaceDown = true,
TargetPlayer = null
});
group.RunLogical();
// Advance to flip phase: both players skip.
world.Commands.Enqueue(new SkipPlayCommand());
group.RunLogical();
world.Commands.Enqueue(new SkipPlayCommand());
group.RunLogical();
world.ReadSingleton<GameState>().Phase.Should().Be(GamePhase.FlipPhase);
// Flip it.
world.Commands.Enqueue(new FlipCardCommand { CardEntity = cardInHand });
group.RunLogical();
world.ReadComponent<Card>(cardInHand).FaceDown.Should().BeFalse();
}
[Fact]
public void SkipPlay_AdvancesTurn()
{
var (world, group) = Setup();
var state = world.ReadSingleton<GameState>();
int originalPlayer = state.CurrentPlayerIndex;
world.Commands.Enqueue(new SkipPlayCommand());
group.RunLogical();
state = world.ReadSingleton<GameState>();
state.CurrentPlayerIndex.Should().Be((originalPlayer + 1) % 2);
}
}
+288
View File
@@ -0,0 +1,288 @@
using System.Text;
using FluentAssertions;
using Game.CardWars;
using OECS;
using Xunit;
using Xunit.Abstractions;
namespace Game.CardWars.Tests;
public class PlayLogTests
{
private readonly ITestOutputHelper _output;
public PlayLogTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public void OneRound_ThreePlayers_PlayLog()
{
CardEffectRegistry.ClearForTests();
var (world, group) = GameFactory.Create(playerCount: 3, seed: 12345);
var log = new StringBuilder();
var players = GameUtil.FindAllEntities<Player>(world);
var state = world.ReadSingleton<GameState>();
LogSection(log, "=== CardWars Play Log — Round 1 ===");
log.AppendLine($"Seed: {state.Seed} | Players: {state.PlayerCount} | Starting: P{state.StartingPlayerIndex}");
log.AppendLine();
// ── Initial hands ──
LogSection(log, "── Starting Hands ──");
for (int i = 0; i < players.Count; i++)
log.AppendLine($" P{i}: {DescribeHand(world, GameUtil.GetHandCards(world, players[i]))}");
log.AppendLine();
// ── Play Phase ──
LogSection(log, "── Play Phase ──");
RunPlayPhase(world, group, players, log);
// ── Flip Phase ──
LogSection(log, "── Flip Phase ──");
RunFlipPhase(world, group, players, log);
// ── Scoring (capture powers BEFORE cleanup) ──
LogSection(log, "── Scoring ──");
var powers = new int[players.Count];
for (int i = 0; i < players.Count; i++)
powers[i] = GameUtil.CalculatePower(world, players[i]);
// Advance to scoring phase and let systems run.
ref var mutable = ref world.GetSingleton<GameState>();
mutable.Phase = GamePhase.Scoring;
world.MarkModified<GameState>(World.SingletonEntity);
group.RunLogical(); // ScoringSystem → CleanupSystem
for (int i = 0; i < players.Count; i++)
log.AppendLine($" P{i} power: {powers[i]}");
int winnerIdx = Array.IndexOf(powers, powers.Max());
log.AppendLine($" Winner: P{winnerIdx} (power={powers[winnerIdx]})");
// Find the castle that was just awarded (it has HeldBy but no Castle component).
var wonCastleEntity = world.GetSources<HeldBy>(players[winnerIdx])
.FirstOrDefault(c => !world.HasComponent<Castle>(c) && !world.HasComponent<CardDef>(c));
log.AppendLine($" Castle awarded to P{winnerIdx}");
log.AppendLine();
// ── Cleanup already ran ──
LogSection(log, "── After Cleanup ──");
state = world.ReadSingleton<GameState>();
log.AppendLine($" Phase: {state.Phase} | Round: {state.RoundNumber}");
for (int i = 0; i < players.Count; i++)
{
var hand = GameUtil.GetHandCards(world, players[i]);
log.AppendLine($" P{i} hand: {DescribeHand(world, hand)}");
}
log.AppendLine();
// ── Castle counts ──
LogSection(log, "── Castle Tally ──");
for (int i = 0; i < players.Count; i++)
{
// A won castle: has HeldBy to this player, no CardDef, no Card, no Leader, no Banner.
var castleEntities = world.GetSources<HeldBy>(players[i])
.Where(c => !world.HasComponent<CardDef>(c)
&& !world.HasComponent<Card>(c)
&& !world.HasComponent<Leader>(c)
&& !world.HasComponent<Banner>(c))
.ToList();
log.AppendLine($" P{i}: {castleEntities.Count} castle(s)");
}
log.AppendLine();
_output.WriteLine(log.ToString());
state.Phase.Should().BeOneOf(GamePhase.PlayPhase, GamePhase.GameOver);
}
// ── Play phase: greedy agent, plays best card each turn ──
private static void RunPlayPhase(World world, SystemGroup group, List<Entity> players, StringBuilder log)
{
var state = world.ReadSingleton<GameState>();
int startIdx = state.StartingPlayerIndex;
int current = startIdx;
int safety = 0;
while (safety++ < 100)
{
state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.PlayPhase) break;
var player = players[state.CurrentPlayerIndex];
var hand = GameUtil.GetHandCards(world, player);
if (hand.Count > 0)
{
var best = PickBestCard(world, hand);
var def = world.ReadComponent<CardDef>(best);
int rank = def.Ranks.Max();
world.Commands.Enqueue(new PlayCardCommand
{
CardEntity = best,
Rank = rank,
FaceDown = false,
TargetPlayer = null
});
group.RunLogical();
log.AppendLine($" P{state.CurrentPlayerIndex} plays [{def.Name}] rank={rank}");
// Resolve any pending choices automatically.
ResolvePendingChoices(world, group, players, log);
}
else
{
world.Commands.Enqueue(new SkipPlayCommand());
group.RunLogical();
log.AppendLine($" P{state.CurrentPlayerIndex} skips (hand empty)");
}
}
state = world.ReadSingleton<GameState>();
log.AppendLine($" Phase → {state.Phase}");
log.AppendLine();
}
// ── Flip phase ──
private static void RunFlipPhase(World world, SystemGroup group, List<Entity> players, StringBuilder log)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.FlipPhase)
{
ref var m = ref world.GetSingleton<GameState>();
m.Phase = GamePhase.FlipPhase;
m.CurrentPlayerIndex = m.StartingPlayerIndex;
world.MarkModified<GameState>(World.SingletonEntity);
}
for (int i = 0; i < players.Count; i++)
{
state = world.ReadSingleton<GameState>();
var player = players[state.CurrentPlayerIndex];
var field = GameUtil.GetFieldCards(world, player);
bool anyFaceDown = field.Any(c =>
world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).FaceDown);
if (anyFaceDown)
{
var fd = field.First(c =>
world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).FaceDown);
world.Commands.Enqueue(new FlipCardCommand { CardEntity = fd });
group.RunLogical();
var def = world.ReadComponent<CardDef>(fd);
log.AppendLine($" P{state.CurrentPlayerIndex} flips [{def.Name}]");
ResolvePendingChoices(world, group, players, log);
}
else
{
log.AppendLine($" P{state.CurrentPlayerIndex} skips flip (no face-down)");
}
ref var m2 = ref world.GetSingleton<GameState>();
m2.CurrentPlayerIndex = (m2.CurrentPlayerIndex + 1) % m2.PlayerCount;
world.MarkModified<GameState>(World.SingletonEntity);
}
log.AppendLine();
}
// ── Pending choice auto-resolver ──
private static void ResolvePendingChoices(World world, SystemGroup group, List<Entity> players, StringBuilder log)
{
while (PendingChoice.Any(world))
{
Entity target = PickDefaultTarget(world, players);
world.Commands.Enqueue(new ResolveChoiceCommand { Target = target });
group.RunLogical();
log.AppendLine($" ↳ auto-resolve → {DescribeEntity(world, target)}");
}
}
private static Entity PickDefaultTarget(World world, List<Entity> players)
{
if (world.HasSingleton<PendingMercenary>())
{
var p = world.ReadSingleton<PendingMercenary>();
return GameUtil.GetFieldCards(world, p.Player)
.FirstOrDefault(c => world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).Rank > 0);
}
if (world.HasSingleton<PendingDancer>())
{
var p = world.ReadSingleton<PendingDancer>();
return GameUtil.GetFieldCards(world, p.Player)
.FirstOrDefault(c => world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).Rank > 0);
}
if (world.HasSingleton<PendingPaladin>())
{
var p = world.ReadSingleton<PendingPaladin>();
var powered = GameUtil.GetFieldCards(world, p.Player)
.FirstOrDefault(c => world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).Rank > 0);
return powered != Entity.Null ? powered : CardEffectHelpers.GetBanner(world, p.Player);
}
if (world.HasSingleton<PendingNun>())
{
var p = world.ReadSingleton<PendingNun>();
return GameUtil.GetFieldCards(world, p.Player).FirstOrDefault();
}
if (world.HasSingleton<PendingScout>())
{
var p = world.ReadSingleton<PendingScout>();
return players.FirstOrDefault(pl => pl != p.Player);
}
if (world.HasSingleton<PendingPegasus>())
{
var p = world.ReadSingleton<PendingPegasus>();
return GameUtil.GetFieldCards(world, p.Player).FirstOrDefault(c => c != p.CardEntity);
}
if (world.HasSingleton<PendingCurseMaster>())
{
return GameUtil.FindAllEntities<Horn>(world).FirstOrDefault();
}
return Entity.Null;
}
// ── Helpers ──
private static Entity PickBestCard(World world, List<Entity> hand)
{
Entity best = Entity.Null;
int bestRank = -999;
foreach (var c in hand)
{
int maxRank = world.ReadComponent<CardDef>(c).Ranks.Max();
if (maxRank > bestRank) { bestRank = maxRank; best = c; }
}
return best;
}
private static string DescribeHand(World world, List<Entity> hand)
{
var cards = hand.Where(c => world.HasComponent<CardDef>(c)).ToList();
if (cards.Count == 0) return "(empty)";
return string.Join(" ", cards.Select(c =>
{
var def = world.ReadComponent<CardDef>(c);
return $"[{def.Name} {string.Join("/", def.Ranks)}]";
}));
}
private static string DescribeEntity(World world, Entity e)
{
if (e == Entity.Null) return "(none)";
if (world.HasComponent<CardDef>(e)) return $"[{world.ReadComponent<CardDef>(e).Name}]";
if (world.HasComponent<Player>(e)) return $"P{world.ReadComponent<Player>(e).Index}";
if (world.HasComponent<Horn>(e)) return "[Horn token]";
if (world.HasComponent<Skull>(e)) return "[Skull token]";
if (world.HasComponent<Banner>(e)) return "[Banner]";
return e.ToString();
}
private static void LogSection(StringBuilder log, string title) => log.AppendLine(title);
}
+113
View File
@@ -0,0 +1,113 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Loads card definitions from a CSV and creates card entities in the world.
/// </summary>
public static class CardDataLoader
{
/// <summary>
/// Creates all card definition entities in the world from CSV data.
/// Returns a list of (entity, CardDef) pairs.
/// </summary>
public static List<(Entity Entity, CardDef Def)> LoadDefinitions(World world, string csv)
{
var results = new List<(Entity, CardDef)>();
var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (lines.Length < 2)
return results;
// Skip header line.
for (int i = 1; i < lines.Length; i++)
{
var line = lines[i].Trim();
if (string.IsNullOrEmpty(line)) continue;
var def = ParseLine(line);
if (def.Name == null) continue;
var entity = world.CreateEntity();
world.AddComponent(entity, def);
results.Add((entity, def));
}
return results;
}
private static CardDef ParseLine(string line)
{
// Simple CSV parsing: name,ranks,effect
// Ranks are semicolon-separated.
var parts = SplitCsv(line);
if (parts.Length < 3)
return default;
var name = parts[0].Trim();
var rankStrs = parts[1].Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var ranks = new int[rankStrs.Length];
for (int i = 0; i < rankStrs.Length; i++)
ranks[i] = int.Parse(rankStrs[i]);
var effect = ParseEffect(parts[2].Trim());
return new CardDef
{
Name = name,
Ranks = ranks,
Effect = effect,
Kind = CardKind.Public
};
}
private static string[] SplitCsv(string line)
{
var result = new List<string>();
var current = new System.Text.StringBuilder();
bool inQuotes = false;
for (int i = 0; i < line.Length; i++)
{
char c = line[i];
if (c == '"')
{
inQuotes = !inQuotes;
}
else if (c == ',' && !inQuotes)
{
result.Add(current.ToString());
current.Clear();
}
else
{
current.Append(c);
}
}
result.Add(current.ToString());
return result.ToArray();
}
private static CardEffect ParseEffect(string effect)
{
return effect switch
{
string s when s.Contains("战士") => CardEffect.Warrior,
string s when s.Contains("弓手") => CardEffect.Archer,
string s when s.Contains("佣兵") => CardEffect.Mercenary,
string s when s.Contains("商人") => CardEffect.Merchant,
string s when s.Contains("舞姬") => CardEffect.Dancer,
string s when s.Contains("圣骑士") => CardEffect.Paladin,
string s when s.Contains("飞马") => CardEffect.Pegasus,
string s when s.Contains("诅咒师") => CardEffect.CurseMaster,
string s when s.Contains("魔导士") => CardEffect.Mage,
string s when s.Contains("公主") => CardEffect.Princess,
string s when s.Contains("决斗家") => CardEffect.Duelist,
string s when s.Contains("修女") => CardEffect.Nun,
string s when s.Contains("斥候") => CardEffect.Scout,
string s when s.Contains("女巫") => CardEffect.Witch,
string s when s.Contains("盗贼") => CardEffect.Thief,
_ => CardEffect.None
};
}
}
+96
View File
@@ -0,0 +1,96 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// A card effect that can be registered with the effect dispatcher.
/// </summary>
public interface ICardEffect
{
CardEffect Effect { get; }
/// <summary>
/// Called when the card is played face-up or flipped face-up.
/// Return true if fully resolved. Return false if a pending component
/// was set and the effect needs a player command to continue.
/// </summary>
bool Resolve(World world, Entity card, Entity player, Entity owner);
/// <summary>
/// Called when the player provides a choice to continue a pending effect.
/// The effect handler is responsible for reading the correct pending
/// component type from the singleton and removing it when done.
/// </summary>
bool ResolveChoice(World world, Entity target);
}
/// <summary>
/// Registry of card effects, ordered by registration.
/// </summary>
public static class CardEffectRegistry
{
private static readonly List<ICardEffect> _effects = new();
private static bool _frozen;
public static void Add<TEffect>() where TEffect : ICardEffect, new()
{
if (_frozen)
throw new InvalidOperationException("Cannot add effects after the registry is frozen.");
_effects.Add(new TEffect());
}
public static void Freeze() => _frozen = true;
/// <summary>
/// Resolve a card effect. Returns true if fully resolved.
/// </summary>
public static bool Dispatch(World world, CardEffect effect, Entity card, Entity player, Entity owner)
{
foreach (var e in _effects)
{
if (e.Effect == effect)
return e.Resolve(world, card, player, owner);
}
return true;
}
/// <summary>
/// Look up the handler for the currently pending effect and resolve it.
/// </summary>
public static bool DispatchChoice(World world, Entity target)
{
// Find which pending component is active and dispatch to the matching handler.
if (world.HasSingleton<PendingMercenary>())
return DispatchByEffect(world, CardEffect.Mercenary, target);
if (world.HasSingleton<PendingDancer>())
return DispatchByEffect(world, CardEffect.Dancer, target);
if (world.HasSingleton<PendingPaladin>())
return DispatchByEffect(world, CardEffect.Paladin, target);
if (world.HasSingleton<PendingNun>())
return DispatchByEffect(world, CardEffect.Nun, target);
if (world.HasSingleton<PendingScout>())
return DispatchByEffect(world, CardEffect.Scout, target);
if (world.HasSingleton<PendingPegasus>())
return DispatchByEffect(world, CardEffect.Pegasus, target);
if (world.HasSingleton<PendingCurseMaster>())
return DispatchByEffect(world, CardEffect.CurseMaster, target);
return true;
}
private static bool DispatchByEffect(World world, CardEffect effect, Entity target)
{
foreach (var e in _effects)
{
if (e.Effect == effect)
return e.ResolveChoice(world, target);
}
return true;
}
public static void ClearForTests()
{
_effects.Clear();
_frozen = false;
}
}
+402
View File
@@ -0,0 +1,402 @@
using OECS;
namespace Game.CardWars;
// ────────────────────────────────────────────────────────────
// Simple effects that resolve immediately.
// ────────────────────────────────────────────────────────────
public class WarriorEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Warrior;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
if (HasOtherOnField(world, owner, card, CardEffect.Warrior))
CardEffectHelpers.AddHorn(world, card);
return true;
}
public bool ResolveChoice(World world, Entity target) => true;
private static bool HasOtherOnField(World world, Entity player, Entity self, CardEffect effect)
{
return GameUtil.GetFieldCards(world, player)
.Any(c => c != self && world.HasComponent<CardDef>(c)
&& world.ReadComponent<CardDef>(c).Effect == effect);
}
}
public class ArcherEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Archer;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
if (HasOtherOnField(world, owner, card, CardEffect.Archer))
{
var banner = CardEffectHelpers.GetBanner(world, owner);
if (banner != Entity.Null) CardEffectHelpers.AddHorn(world, banner);
}
return true;
}
public bool ResolveChoice(World world, Entity target) => true;
private static bool HasOtherOnField(World world, Entity player, Entity self, CardEffect effect)
{
return GameUtil.GetFieldCards(world, player)
.Any(c => c != self && world.HasComponent<CardDef>(c)
&& world.ReadComponent<CardDef>(c).Effect == effect);
}
}
public class MercenaryEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Mercenary;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
if (!HasOtherOnField(world, owner, card, CardEffect.Mercenary))
return true;
world.SetSingleton(new PendingMercenary { CardEntity = card, Player = player });
return false;
}
public bool ResolveChoice(World world, Entity target)
{
var pending = world.ReadSingleton<PendingMercenary>();
if (target != Entity.Null)
CardEffectHelpers.AddSkull(world, target);
world.RemoveSingleton<PendingMercenary>();
return true;
}
private static bool HasOtherOnField(World world, Entity player, Entity self, CardEffect effect)
{
return GameUtil.GetFieldCards(world, player)
.Any(c => c != self && world.HasComponent<CardDef>(c)
&& world.ReadComponent<CardDef>(c).Effect == effect);
}
}
public class MerchantEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Merchant;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
world.Commands.Enqueue(new DrawCardCommand { Player = player, Kind = null });
world.Commands.Enqueue(new ExtraActionCommand());
return true;
}
public bool ResolveChoice(World world, Entity target) => true;
}
public class DancerEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Dancer;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
var powered = GameUtil.GetFieldCards(world, owner)
.Where(c => world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).Rank > 0)
.ToList();
if (powered.Count == 0) return true;
if (powered.Count <= 2)
{
foreach (var c in powered)
CardEffectHelpers.AddHorn(world, c);
return true;
}
world.SetSingleton(new PendingDancer { CardEntity = card, Player = player, Step = 0 });
return false;
}
public bool ResolveChoice(World world, Entity target)
{
var pending = world.ReadSingleton<PendingDancer>();
if (target != Entity.Null)
CardEffectHelpers.AddHorn(world, target);
if (pending.Step == 0)
{
world.SetSingleton(new PendingDancer { CardEntity = pending.CardEntity, Player = pending.Player, Step = 1 });
return false;
}
world.RemoveSingleton<PendingDancer>();
return true;
}
}
public class PaladinEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Paladin;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
world.SetSingleton(new PendingPaladin { CardEntity = card, Player = player });
return false;
}
public bool ResolveChoice(World world, Entity target)
{
if (target != Entity.Null)
CardEffectHelpers.AddHorn(world, target);
world.RemoveSingleton<PendingPaladin>();
return true;
}
}
public class PrincessEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Princess;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
world.Commands.Enqueue(new ReplaceCastleCommand());
return true;
}
public bool ResolveChoice(World world, Entity target) => true;
}
public class DuelistEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Duelist;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
world.Commands.Enqueue(new EndPlayPhaseCommand());
return true;
}
public bool ResolveChoice(World world, Entity target) => true;
}
public class NunEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Nun;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
var fieldCards = GameUtil.GetFieldCards(world, owner);
if (fieldCards.Count == 0)
{
world.Commands.Enqueue(new DrawCardCommand { Player = player, Kind = null });
return true;
}
world.SetSingleton(new PendingNun { CardEntity = card, Player = player });
return false;
}
public bool ResolveChoice(World world, Entity target)
{
var pending = world.ReadSingleton<PendingNun>();
if (target != Entity.Null)
{
var discardDeck = CardEffectHelpers.GetDiscardDeck(world, pending.Player);
world.RemoveComponent<OnField>(target);
if (world.HasComponent<Card>(target))
world.RemoveComponent<Card>(target);
world.AddComponent(target, new InDeck { Target = discardDeck });
}
world.Commands.Enqueue(new DrawCardCommand { Player = pending.Player, Kind = null });
world.RemoveSingleton<PendingNun>();
return true;
}
}
public class ScoutEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Scout;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
world.SetSingleton(new PendingScout { CardEntity = card, Player = player });
return false;
}
public bool ResolveChoice(World world, Entity target)
{
var pending = world.ReadSingleton<PendingScout>();
if (target != Entity.Null && world.HasComponent<Player>(target))
{
foreach (var c in GameUtil.GetFieldCards(world, target))
{
if (world.TryGetComponent<Card>(c, out var cd) && cd.FaceDown)
{
ref var cardRef = ref world.GetComponent<Card>(c);
cardRef.FaceDown = false;
world.MarkModified<Card>(c);
}
}
}
world.RemoveSingleton<PendingScout>();
return true;
}
}
public class WitchEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Witch;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
var banner = CardEffectHelpers.GetBanner(world, owner);
if (banner != Entity.Null)
CardEffectHelpers.AddSkull(world, banner);
return true;
}
public bool ResolveChoice(World world, Entity target) => true;
}
public class ThiefEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Thief;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
return true;
}
public bool ResolveChoice(World world, Entity target) => true;
}
// ────────────────────────────────────────────────────────────
// Face-down flip effects
// ────────────────────────────────────────────────────────────
public class PegasusEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Pegasus;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
world.SetSingleton(new PendingPegasus { CardEntity = card, Player = player });
return false;
}
public bool ResolveChoice(World world, Entity target)
{
var pending = world.ReadSingleton<PendingPegasus>();
if (target != Entity.Null && target != pending.CardEntity)
{
world.RemoveComponent<OnField>(target);
if (world.HasComponent<Card>(target))
world.RemoveComponent<Card>(target);
world.AddComponent(target, new HeldBy { Target = pending.Player });
}
world.RemoveSingleton<PendingPegasus>();
return true;
}
}
public class CurseMasterEffect : ICardEffect
{
public CardEffect Effect => CardEffect.CurseMaster;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
world.SetSingleton(new PendingCurseMaster { CardEntity = card, Player = player });
return false;
}
public bool ResolveChoice(World world, Entity target)
{
var pending = world.ReadSingleton<PendingCurseMaster>();
if (target != Entity.Null && world.HasComponent<Horn>(target))
{
var placedTargets = world.GetSources<PlacedOn>(target).ToList();
world.DestroyEntity(target);
foreach (var pt in placedTargets)
{
var skull = world.CreateEntity();
world.AddComponent(skull, new Skull());
world.AddComponent(skull, new PlacedOn { Target = pt });
}
}
world.RemoveSingleton<PendingCurseMaster>();
return true;
}
}
public class MageEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Mage;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
Entity best = Entity.Null;
int bestRank = int.MinValue;
foreach (var p in GameUtil.FindAllEntities<Player>(world))
{
foreach (var c in GameUtil.GetFieldCards(world, p))
{
if (world.TryGetComponent<Card>(c, out var cd) && !cd.FaceDown && cd.Rank > bestRank)
{
bestRank = cd.Rank;
best = c;
}
}
}
if (best != Entity.Null)
CardEffectHelpers.AddSkull(world, best);
return true;
}
public bool ResolveChoice(World world, Entity target) => true;
}
// ────────────────────────────────────────────────────────────
// Shared helpers
// ────────────────────────────────────────────────────────────
public static class CardEffectHelpers
{
public static void AddHorn(World world, Entity target)
{
var horn = world.CreateEntity();
world.AddComponent(horn, new Horn());
world.AddComponent(horn, new PlacedOn { Target = target });
}
public static void AddSkull(World world, Entity target)
{
var skull = world.CreateEntity();
world.AddComponent(skull, new Skull());
world.AddComponent(skull, new PlacedOn { Target = target });
}
public static Entity GetBanner(World world, Entity player)
{
using var iter = world.Select<Banner>();
while (iter.MoveNext())
{
if (iter.CurrentEntity == World.SingletonEntity) continue;
var onField = world.GetSources<OnField>(player);
if (onField.Contains(iter.CurrentEntity))
return iter.CurrentEntity;
}
return Entity.Null;
}
public static Entity GetDiscardDeck(World world, Entity player)
{
using var iter = world.Select<FactionDeck>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
return iter.CurrentEntity;
}
return Entity.Null;
}
}
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<RootNamespace>Game.CardWars</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OECS\OECS.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OECS.SourceGen\OECS.SourceGen.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>
+173
View File
@@ -0,0 +1,173 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
[MessagePackObject]
public struct SkipPlayCommand : ICommand
{
public void Execute(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.PlayPhase) return;
ref var mutable = ref world.GetSingleton<GameState>();
mutable.PlayPhaseEnded = true;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
[MessagePackObject]
public struct FlipCardCommand : ICommand
{
[Key(0)] public Entity CardEntity;
public void Execute(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.FlipPhase) return;
if (!world.IsAlive(CardEntity)) return;
if (!world.HasComponent<Card>(CardEntity)) return;
ref var card = ref world.GetComponent<Card>(CardEntity);
if (!card.FaceDown) return;
card.FaceDown = false;
world.MarkModified<Card>(CardEntity);
var def = world.ReadComponent<CardDef>(CardEntity);
Entity player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
// Resolve flip effects via registry.
CardEffectRegistry.Dispatch(world, def.Effect, CardEntity, player, player);
}
}
[MessagePackObject]
public struct SkipFlipCommand : ICommand
{
public void Execute(World world)
{
// Skip is only valid if no face-down cards remain.
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.FlipPhase) return;
Entity player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
var fieldCards = GameUtil.GetFieldCards(world, player);
bool hasFaceDown = fieldCards.Any(c =>
world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).FaceDown);
if (hasFaceDown) return;
// Advance is handled by FlipPhaseSystem.
}
}
[MessagePackObject]
public struct DrawCardCommand : ICommand
{
[Key(0)] public Entity Player;
[Key(1)] public CardKind? Kind;
public void Execute(World world)
{
if (!world.IsAlive(Player)) return;
var kind = Kind ?? CardKind.Public;
var deck = GetDeck(world, kind);
if (deck == Entity.Null) return;
var cards = world.GetSources<InDeck>(deck);
if (cards.Count == 0)
{
// Recycle: for now, faction discards recycle to the same deck.
GameUtil.RecycleDiscard(world, deck, deck);
cards = world.GetSources<InDeck>(deck);
if (cards.Count == 0) return;
}
GameUtil.DrawCard(world, deck, Player);
}
private static Entity GetDeck(World world, CardKind kind)
{
if (kind == CardKind.Public)
{
using var iter = world.Select<PublicDeck>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
return iter.CurrentEntity;
}
}
else
{
using var iter = world.Select<FactionDeck>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
return iter.CurrentEntity;
}
}
return Entity.Null;
}
}
[MessagePackObject]
public struct ExtraActionCommand : ICommand
{
public void Execute(World world) { }
}
[MessagePackObject]
public struct EndPlayPhaseCommand : ICommand
{
public void Execute(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Phase != GamePhase.PlayPhase) return;
world.SetSingleton(new PendingDuelist { PlayerIndex = state.CurrentPlayerIndex });
}
}
[MessagePackObject]
public struct ReplaceCastleCommand : ICommand
{
public void Execute(World world)
{
var state = world.ReadSingleton<GameState>();
var castles = GameUtil.FindAllEntities<Castle>(world);
foreach (var c in castles)
world.DestroyEntity(c);
var colors = new[] { CastleColor.Black, CastleColor.White, CastleColor.Blue,
CastleColor.Brown, CastleColor.Yellow, CastleColor.Indigo, CastleColor.Gold };
var seed = state.Seed;
for (int i = 0; i < 3; i++)
{
int idx = Mulberry32.NextInt(ref seed, 0, colors.Length - 1);
var castle = world.CreateEntity();
world.AddComponent(castle, new Castle { Color = colors[idx] });
}
}
}
/// <summary>
/// Player makes a choice to resolve a pending card effect.
/// The target entity depends on the effect:
/// - Mercenary, Dancer, Paladin, Nun: a field card entity
/// - Scout: a player entity
/// - Pegasus: a field card to return (or null)
/// - CurseMaster: a horn token entity
/// </summary>
[MessagePackObject]
public struct ResolveChoiceCommand : ICommand
{
[Key(0)] public Entity Target;
public void Execute(World world)
{
if (!PendingChoice.Any(world)) return;
CardEffectRegistry.DispatchChoice(world, Target);
}
}
+28
View File
@@ -0,0 +1,28 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Initializes a fresh game with the given player count and seed.
/// </summary>
[MessagePackObject]
public struct NewGameCommand : ICommand
{
[Key(0)] public int PlayerCount;
[Key(1)] public uint Seed;
[Key(2)] public string CardDataCsv;
public void Execute(World world)
{
world.SetSingleton(new GameState
{
Phase = GamePhase.Setup,
PlayerCount = PlayerCount,
CurrentPlayerIndex = 0,
StartingPlayerIndex = 0,
RoundNumber = 0,
Seed = Seed
});
}
}
+62
View File
@@ -0,0 +1,62 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Play a card from hand to the field, choosing a rank.
/// Resolves on-play effects via the CardEffectRegistry.
/// </summary>
[MessagePackObject]
public struct PlayCardCommand : ICommand
{
[Key(0)] public Entity CardEntity;
[Key(1)] public int Rank;
[Key(2)] public bool FaceDown;
[Key(3)] public Entity? TargetPlayer;
public void Execute(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase is not GamePhase.PlayPhase) return;
if (!world.IsAlive(CardEntity)) return;
Entity player = FindPlayerByIndex(world, state.CurrentPlayerIndex);
if (player == Entity.Null) return;
// Verify the card is in the player's hand.
if (!world.HasComponent<HeldBy>(CardEntity)) return;
var heldBy = world.ReadComponent<HeldBy>(CardEntity);
if (heldBy.Target != player) return;
var def = world.ReadComponent<CardDef>(CardEntity);
// Determine target: Witch/Thief are played to another player's field.
Entity fieldOwner = def.Effect is CardEffect.Witch or CardEffect.Thief
? (TargetPlayer ?? player)
: player;
// Move from hand to field.
world.RemoveComponent<HeldBy>(CardEntity);
world.AddComponent(CardEntity, new OnField { Target = fieldOwner });
world.AddComponent(CardEntity, new Card { Rank = Rank, FaceDown = FaceDown });
// Resolve on-play effects via registry.
if (!FaceDown)
CardEffectRegistry.Dispatch(world, def.Effect, CardEntity, player, fieldOwner);
world.MarkModified<GameState>(World.SingletonEntity);
}
public static Entity FindPlayerByIndex(World world, int index)
{
using var iter = world.Select<Player>();
while (iter.MoveNext())
{
if (iter.CurrentEntity == World.SingletonEntity) continue;
if (iter.Current1.Index == index)
return iter.CurrentEntity;
}
return Entity.Null;
}
}
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for a player's banner entity.
/// </summary>
[MessagePackObject]
public struct Banner { }
+17
View File
@@ -0,0 +1,17 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Instance data for a card that has been played to the field.
/// Cards in hand/deck only have CardDef; this is added when played.
/// </summary>
[MessagePackObject]
public record struct Card
{
/// <summary>The rank chosen for this play (one of CardDef.Ranks).</summary>
[Key(0)] public int Rank;
/// <summary>Whether the card is face-down on the field.</summary>
[Key(1)] public bool FaceDown;
}
+17
View File
@@ -0,0 +1,17 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Static card definition — name, possible ranks, effect, and kind.
/// Cards in the deck/hand only have CardDef. When played to the field,
/// they also get a Card component with the chosen rank.
/// </summary>
[MessagePackObject]
public record struct CardDef
{
[Key(0)] public string Name;
[Key(1)] public int[] Ranks;
[Key(2)] public CardEffect Effect;
[Key(3)] public CardKind Kind;
}
+12
View File
@@ -0,0 +1,12 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// A castle that can be won each round.
/// </summary>
[MessagePackObject]
public record struct Castle
{
[Key(0)] public CastleColor Color;
}
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for a faction deck entity.
/// </summary>
[MessagePackObject]
public struct FactionDeck { }
+14
View File
@@ -0,0 +1,14 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Relationship from a card entity to the player who holds it in hand.
/// Added to the card: Source=card, Target=player.
/// </summary>
[MessagePackObject]
public record struct HeldBy : IRelationship
{
[Key(0)] public Entity Target { get; set; }
}
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for a horn token. Can be placed on cards, banners, or leaders.
/// </summary>
[MessagePackObject]
public struct Horn { }
+13
View File
@@ -0,0 +1,13 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Relationship from a card entity to a deck entity.
/// </summary>
[MessagePackObject]
public record struct InDeck : IRelationship
{
[Key(0)] public Entity Target { get; set; }
}
+10
View File
@@ -0,0 +1,10 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component marking a card entity as the leader card.
/// The leader is always on the field and contributes its rank to power.
/// </summary>
[MessagePackObject]
public struct Leader { }
+13
View File
@@ -0,0 +1,13 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Relationship from a card entity to the player entity whose field it's on.
/// </summary>
[MessagePackObject]
public record struct OnField : IRelationship
{
[Key(0)] public Entity Target { get; set; }
}
+86
View File
@@ -0,0 +1,86 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
// ────────────────────────────────────────────────────────────
// Per-effect pending choice components.
// Each is placed on the singleton entity when a card effect
// requires player input. Their presence blocks game advancement.
// ────────────────────────────────────────────────────────────
/// <summary>Mercenary: choose a combatant to give a skull.</summary>
[MessagePackObject]
public record struct PendingMercenary
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>Dancer: choose up to 2 combatants for horn. Step tracks progress.</summary>
[MessagePackObject]
public record struct PendingDancer
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
[Key(2)] public int Step; // 0 = first choice, 1 = second choice
}
/// <summary>Paladin: choose a combatant or banner for horn.</summary>
[MessagePackObject]
public record struct PendingPaladin
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>Nun: choose a field card to discard.</summary>
[MessagePackObject]
public record struct PendingNun
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>Scout: choose an opponent player to scout.</summary>
[MessagePackObject]
public record struct PendingScout
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>Pegasus: choose a field card to return to hand (or null to pass).</summary>
[MessagePackObject]
public record struct PendingPegasus
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>CurseMaster: choose a horn token to turn into skull.</summary>
[MessagePackObject]
public record struct PendingCurseMaster
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
// ────────────────────────────────────────────────────────────
// Helper to detect any pending component on the singleton.
// ────────────────────────────────────────────────────────────
public static class PendingChoice
{
/// <summary>Returns true if any pending choice component exists on the singleton.</summary>
public static bool Any(World world)
{
return world.HasSingleton<PendingMercenary>()
|| world.HasSingleton<PendingDancer>()
|| world.HasSingleton<PendingPaladin>()
|| world.HasSingleton<PendingNun>()
|| world.HasSingleton<PendingScout>()
|| world.HasSingleton<PendingPegasus>()
|| world.HasSingleton<PendingCurseMaster>();
}
}
@@ -0,0 +1,14 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Placed on the singleton when a 决斗家 (Duelist) is played.
/// The play phase ends when the turn comes back to this player.
/// Removed automatically when the phase transitions.
/// </summary>
[MessagePackObject]
public struct PendingDuelist
{
[Key(0)] public int PlayerIndex;
}
+13
View File
@@ -0,0 +1,13 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Relationship from a horn/skull token entity to the target entity it's placed on.
/// </summary>
[MessagePackObject]
public record struct PlacedOn : IRelationship
{
[Key(0)] public Entity Target { get; set; }
}
+12
View File
@@ -0,0 +1,12 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag + index for a player entity.
/// </summary>
[MessagePackObject]
public record struct Player
{
[Key(0)] public int Index;
}
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for the public deck entity.
/// </summary>
[MessagePackObject]
public struct PublicDeck { }
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for a skull token. Can be placed on cards, banners, or leaders.
/// </summary>
[MessagePackObject]
public struct Skull { }
+52
View File
@@ -0,0 +1,52 @@
namespace Game.CardWars;
public enum CardKind : byte
{
Public = 0,
Faction = 1
}
public enum CardEffect : byte
{
None = 0,
// On-play effects
Warrior,
Archer,
Mercenary,
Merchant,
Dancer,
Paladin,
Princess,
Duelist,
Nun,
Scout,
Witch,
Thief,
// On-flip effects (played face-down)
Pegasus,
CurseMaster,
Mage
}
public enum CastleColor : byte
{
Black = 0,
White = 1,
Blue = 2,
Brown = 3,
Yellow = 4,
Indigo = 5,
Gold = 6
}
public enum GamePhase : byte
{
Setup = 0,
PlayPhase = 1,
FlipPhase = 2,
Scoring = 3,
Cleanup = 4,
GameOver = 5
}
+51
View File
@@ -0,0 +1,51 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Factory for creating a fully configured game world and system group.
/// </summary>
public static class GameFactory
{
public static string DefaultCardData =>
"name,ranks,effect\n" +
"战士,3;4;4;5,战士\n" +
"弓手,3;4;4;5,弓手\n" +
"佣兵,4;5;5;6,佣兵\n" +
"商人,0;1,商人\n" +
"舞姬,0;1,舞姬\n" +
"圣骑士,2;3,圣骑士\n" +
"飞马,2;3,飞马\n" +
"诅咒师,1;2,诅咒师\n" +
"魔导士,0;1,魔导士\n" +
"公主,4,公主\n" +
"决斗家,4,决斗家\n" +
"修女,3,修女\n" +
"斥候,2,斥候\n" +
"女巫,-1,女巫\n" +
"盗贼,-1,盗贼\n";
public static (World World, SystemGroup Group) Create(int playerCount, uint seed = 42)
{
var world = new World();
var group = new SystemGroup(world);
world.Commands.Enqueue(new NewGameCommand
{
PlayerCount = playerCount,
Seed = seed,
CardDataCsv = DefaultCardData
});
group.Add(new GameSetupSystem(playerCount, DefaultCardData));
group.Add(new PlayPhaseSystem());
group.Add(new FlipPhaseSystem());
group.Add(new ScoringSystem());
group.Add(new CleanupSystem());
// Run setup.
group.RunLogical();
return (world, group);
}
}
+162
View File
@@ -0,0 +1,162 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Shared utility methods for querying and manipulating the game world.
/// </summary>
public static class GameUtil
{
public static Entity FindEntity<T>(World world) where T : struct
{
using var iter = world.Select<T>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
return iter.CurrentEntity;
}
return Entity.Null;
}
public static List<Entity> FindAllEntities<T>(World world) where T : struct
{
var list = new List<Entity>();
using var iter = world.Select<T>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
list.Add(iter.CurrentEntity);
}
return list;
}
/// <summary>Draw the top card from a deck and add it to the player's hand.</summary>
public static bool DrawCard(World world, Entity deck, Entity player)
{
var cards = world.GetSources<InDeck>(deck);
if (cards.Count == 0) return false;
var cardEntity = cards.First();
world.RemoveComponent<InDeck>(cardEntity);
world.AddComponent(cardEntity, new HeldBy { Target = player });
return true;
}
/// <summary>Shuffle cards in a deck using Fisher-Yates with the given seed.</summary>
public static void ShuffleDeck(World world, Entity deck, ref uint seed)
{
var cards = world.GetSources<InDeck>(deck).ToArray();
for (int i = cards.Length - 1; i > 0; i--)
{
int j = Mulberry32.NextInt(ref seed, 0, i);
(cards[i], cards[j]) = (cards[j], cards[i]);
}
for (int i = cards.Length - 1; i >= 0; i--)
world.RemoveComponent<InDeck>(cards[i]);
for (int i = 0; i < cards.Length; i++)
world.AddComponent(cards[i], new InDeck { Target = deck });
}
/// <summary>Get all field cards for a player.</summary>
public static List<Entity> GetFieldCards(World world, Entity player)
{
return world.GetSources<OnField>(player).ToList();
}
/// <summary>Get all cards in a player's hand.</summary>
public static List<Entity> GetHandCards(World world, Entity player)
{
return world.GetSources<HeldBy>(player).ToList();
}
/// <summary>Calculate a player's total battle power.</summary>
public static int CalculatePower(World world, Entity player)
{
int total = 0;
// Leader power.
using var leaderIter = world.Select<Leader>();
while (leaderIter.MoveNext())
{
if (leaderIter.CurrentEntity == World.SingletonEntity) continue;
var onFieldSources = world.GetSources<OnField>(player);
if (onFieldSources.Contains(leaderIter.CurrentEntity))
{
if (world.TryGetComponent<Card>(leaderIter.CurrentEntity, out var leaderCard))
total += leaderCard.Rank;
}
}
// Find the player's banner.
var banner = CardEffectHelpers.GetBanner(world, player);
bool bannerHasHorn = banner != Entity.Null && world.HasComponent<Horn>(banner);
bool bannerHasSkull = banner != Entity.Null && world.HasComponent<Skull>(banner);
// Field cards.
var fieldCards = GetFieldCards(world, player);
int poweredCount = 0;
foreach (var card in fieldCards)
{
if (!world.TryGetComponent<Card>(card, out var cardData)) continue;
if (cardData.FaceDown) continue;
if (cardData.Rank <= 0) continue;
poweredCount++;
int rank = cardData.Rank;
if (world.HasComponent<Horn>(card))
rank *= 2;
if (world.HasComponent<Skull>(card))
rank = 0;
total += rank;
}
if (bannerHasHorn) total += poweredCount;
if (bannerHasSkull) total -= poweredCount;
return total;
}
/// <summary>Move all field cards (except leaders and banners) to the discard pile.</summary>
public static void DiscardField(World world, Entity player, Entity discardDeck)
{
var fieldCards = GetFieldCards(world, player);
var toDiscard = new List<Entity>();
foreach (var card in fieldCards)
{
// Keep leaders and banners on the field.
if (world.HasComponent<Leader>(card)) continue;
if (world.HasComponent<Banner>(card)) continue;
toDiscard.Add(card);
}
foreach (var card in toDiscard)
{
world.RemoveComponent<OnField>(card);
world.RemoveComponent<Card>(card);
world.AddComponent(card, new InDeck { Target = discardDeck });
}
// Destroy horns and skulls placed on discarded cards.
var tokenTargets = new HashSet<Entity>();
foreach (var card in toDiscard)
{
foreach (var token in world.GetSources<PlacedOn>(card))
tokenTargets.Add(token);
}
foreach (var token in tokenTargets)
world.DestroyEntity(token);
}
/// <summary>Recycle discard pile into draw pile.</summary>
public static void RecycleDiscard(World world, Entity drawDeck, Entity discardDeck)
{
var cards = world.GetSources<InDeck>(discardDeck).ToList();
foreach (var card in cards)
{
world.RemoveComponent<InDeck>(card);
world.AddComponent(card, new InDeck { Target = drawDeck });
}
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace Game.CardWars;
/// <summary>
/// Mulberry32 PRNG — a fast, high-quality 32-bit random number generator.
/// </summary>
public static class Mulberry32
{
public static float NextFloat(ref uint state)
{
state += 0x6D2B79F5u;
uint z = state;
z = (z ^ (z >> 15)) * (z | 1u);
z ^= z + (z ^ (z >> 7)) * (z | 61u);
return (z ^ (z >> 14)) / (float)uint.MaxValue;
}
public static int NextInt(ref uint state, int min, int max)
{
return (int)(NextFloat(ref state) * (max - min + 1)) + min;
}
}
+18
View File
@@ -0,0 +1,18 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Global game state on the singleton entity.
/// </summary>
[MessagePackObject]
public record struct GameState
{
[Key(0)] public GamePhase Phase;
[Key(1)] public int PlayerCount;
[Key(2)] public int CurrentPlayerIndex;
[Key(3)] public int StartingPlayerIndex;
[Key(4)] public int RoundNumber;
[Key(5)] public uint Seed;
[Key(6)] public bool PlayPhaseEnded;
}
+14
View File
@@ -0,0 +1,14 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Per-player score tracking.
/// </summary>
[MessagePackObject]
public record struct PlayerScores
{
[Key(0)] public int[] Scores;
[Key(1)] public CastleColor[] WonCastles;
[Key(2)] public int Count;
}
+66
View File
@@ -0,0 +1,66 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Discards field cards, clears tokens, draws new hands, checks game over.
/// </summary>
public class CleanupSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Cleanup) return;
ref var mutable = ref world.GetSingleton<GameState>();
var players = GameUtil.FindAllEntities<Player>(world);
var publicDeck = GameUtil.FindEntity<PublicDeck>(world);
foreach (var player in players)
{
GameUtil.DiscardField(world, player, publicDeck);
// Clear tokens on banners.
var banner = CardEffectHelpers.GetBanner(world, player);
if (banner != Entity.Null)
{
var tokens = world.GetSources<PlacedOn>(banner).ToList();
foreach (var token in tokens)
world.DestroyEntity(token);
}
}
// Draw 2 public cards per player.
foreach (var player in players)
{
for (int i = 0; i < 2; i++)
GameUtil.DrawCard(world, publicDeck, player);
}
// Check game over.
int maxScore = 0;
foreach (var player in players)
{
var castles = world.GetSources<HeldBy>(player)
.Where(c => world.HasComponent<Castle>(c))
.ToList();
maxScore = Math.Max(maxScore, castles.Count);
}
if (maxScore >= 4)
{
mutable.Phase = GamePhase.GameOver;
}
else
{
mutable.Phase = GamePhase.PlayPhase;
mutable.RoundNumber++;
mutable.CurrentPlayerIndex = (mutable.StartingPlayerIndex + 1) % mutable.PlayerCount;
mutable.StartingPlayerIndex = mutable.CurrentPlayerIndex;
mutable.PlayPhaseEnded = false;
}
world.MarkModified<GameState>(World.SingletonEntity);
}
}
+20
View File
@@ -0,0 +1,20 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Manages the flip phase: players take turns flipping cards.
/// Blocks if a PendingChoice exists.
/// </summary>
public class FlipPhaseSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.FlipPhase) return;
// Block if a pending choice exists.
if (PendingChoice.Any(world))
return;
}
}
+104
View File
@@ -0,0 +1,104 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// One-time setup: creates players, leaders, banners, decks, castles, and deals starting hands.
/// </summary>
public class GameSetupSystem : ISystem
{
private readonly int _playerCount;
private readonly string _cardDataCsv;
private bool _hasRun;
public GameSetupSystem(int playerCount, string cardDataCsv)
{
_playerCount = playerCount;
_cardDataCsv = cardDataCsv;
}
public void Run(World world)
{
if (_hasRun) return;
_hasRun = true;
var state = world.ReadSingleton<GameState>();
ref var mutable = ref world.GetSingleton<GameState>();
mutable.PlayerCount = _playerCount;
mutable.Phase = GamePhase.PlayPhase;
mutable.CurrentPlayerIndex = 0;
mutable.StartingPlayerIndex = 0;
mutable.RoundNumber = 1;
world.MarkModified<GameState>(World.SingletonEntity);
// Register card effects.
CardEffectRegistry.Add<WarriorEffect>();
CardEffectRegistry.Add<ArcherEffect>();
CardEffectRegistry.Add<MercenaryEffect>();
CardEffectRegistry.Add<MerchantEffect>();
CardEffectRegistry.Add<DancerEffect>();
CardEffectRegistry.Add<PaladinEffect>();
CardEffectRegistry.Add<PrincessEffect>();
CardEffectRegistry.Add<DuelistEffect>();
CardEffectRegistry.Add<NunEffect>();
CardEffectRegistry.Add<ScoutEffect>();
CardEffectRegistry.Add<WitchEffect>();
CardEffectRegistry.Add<ThiefEffect>();
CardEffectRegistry.Add<PegasusEffect>();
CardEffectRegistry.Add<CurseMasterEffect>();
CardEffectRegistry.Add<MageEffect>();
CardEffectRegistry.Freeze();
// Create public deck.
var publicDeck = world.CreateEntity();
world.AddComponent(publicDeck, new PublicDeck());
// Load card definitions and create card instances for the public deck.
var defs = CardDataLoader.LoadDefinitions(world, _cardDataCsv);
foreach (var (defEntity, _) in defs)
{
world.AddComponent(defEntity, new InDeck { Target = publicDeck });
}
// Shuffle public deck.
var seed = state.Seed;
GameUtil.ShuffleDeck(world, publicDeck, ref seed);
// Create the first castle.
var castle = world.CreateEntity();
world.AddComponent(castle, new Castle { Color = CastleColor.Black });
// Create players.
for (int i = 0; i < _playerCount; i++)
{
var player = world.CreateEntity();
world.AddComponent(player, new Player { Index = i });
// Create leader card (always on field, rank 0 placeholder).
var leader = world.CreateEntity();
world.AddComponent(leader, new Leader());
world.AddComponent(leader, new CardDef { Name = $"领袖{i}", Ranks = new[] { 0 }, Effect = CardEffect.None, Kind = CardKind.Faction });
world.AddComponent(leader, new Card { Rank = 0, FaceDown = false });
world.AddComponent(leader, new OnField { Target = player });
// Create banner.
var banner = world.CreateEntity();
world.AddComponent(banner, new Banner());
world.AddComponent(banner, new OnField { Target = player });
// Create faction deck.
var factionDeck = world.CreateEntity();
world.AddComponent(factionDeck, new FactionDeck());
}
// Deal starting hands: 3 public cards per player.
foreach (var player in GameUtil.FindAllEntities<Player>(world))
{
for (int i = 0; i < 3; i++)
GameUtil.DrawCard(world, publicDeck, player);
}
mutable.Seed = seed;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
+58
View File
@@ -0,0 +1,58 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Manages the play phase: players take turns. If a PendingChoice exists,
/// blocks advancement until the player resolves it.
/// </summary>
public class PlayPhaseSystem : ISystem
{
public void Run(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Phase != GamePhase.PlayPhase) return;
// Block if a pending choice exists.
if (PendingChoice.Any(world))
return;
// If the current player has skipped, move to the next player.
if (state.PlayPhaseEnded)
{
state.PlayPhaseEnded = false;
AdvanceTurn(world, ref state);
return;
}
}
private static void AdvanceTurn(World world, ref GameState state)
{
int next = (state.CurrentPlayerIndex + 1) % state.PlayerCount;
// Duelist: if the next player is the one who played duelist, end the phase.
if (world.HasSingleton<PendingDuelist>())
{
var duelist = world.ReadSingleton<PendingDuelist>();
if (next == duelist.PlayerIndex)
{
world.RemoveSingleton<PendingDuelist>();
state.Phase = GamePhase.FlipPhase;
state.CurrentPlayerIndex = state.StartingPlayerIndex;
world.MarkModified<GameState>(World.SingletonEntity);
return;
}
}
state.CurrentPlayerIndex = next;
// If we've come back to the starting player, everyone has skipped.
if (state.CurrentPlayerIndex == state.StartingPlayerIndex)
{
state.Phase = GamePhase.FlipPhase;
world.RemoveSingleton<PendingDuelist>(); // Clean up in case duelist was never triggered.
}
world.MarkModified<GameState>(World.SingletonEntity);
}
}
+59
View File
@@ -0,0 +1,59 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Calculates each player's battle power, awards the castle to the winner,
/// and checks for game end.
/// </summary>
public class ScoringSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Scoring) return;
ref var mutable = ref world.GetSingleton<GameState>();
var players = GameUtil.FindAllEntities<Player>(world);
if (players.Count == 0) return;
int bestPower = int.MinValue;
Entity winner = Entity.Null;
int winnerIndex = -1;
for (int i = 0; i < players.Count; i++)
{
int idx = (state.StartingPlayerIndex + i) % players.Count;
var player = players[idx];
int power = GameUtil.CalculatePower(world, player);
if (power > bestPower)
{
bestPower = power;
winner = player;
winnerIndex = idx;
}
}
if (winner != Entity.Null)
{
var castles = GameUtil.FindAllEntities<Castle>(world);
if (castles.Count > 0)
{
var castle = castles[0];
world.AddComponent(castle, new HeldBy { Target = winner });
world.RemoveComponent<Castle>(castle);
}
var colors = new[] { CastleColor.Black, CastleColor.White, CastleColor.Blue,
CastleColor.Brown, CastleColor.Yellow, CastleColor.Indigo, CastleColor.Gold };
int colorIdx = Mulberry32.NextInt(ref mutable.Seed, 0, colors.Length - 1);
var newCastle = world.CreateEntity();
world.AddComponent(newCastle, new Castle { Color = colors[colorIdx] });
}
mutable.Phase = GamePhase.Cleanup;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
+107
View File
@@ -0,0 +1,107 @@
# 魔烬纷争
## 游戏概述
魔烬纷争是一款快节奏多人桌面卡牌战术游戏。
玩家扮演一个日式异世界中的领袖,需要带领自己的势力击败其他玩家,建立王国。
支持人数2-5人,每局游戏约10分钟/人。
## 游戏配件
- 公共牌:60张
- 旗帜牌:6张
- 标记若干
- 骷髅标记:15个
- 号角标记:15个
- 城堡标记:33个
- 黑、白、蓝、棕、黄、靛色城堡各5座,正面标记 2 个皇冠。
- 金城堡3座,正面标记 3 个皇冠。
- 背面朝上的城堡当做单个皇冠标记使用。
## 游戏布置
公共布置:
- 将公共牌洗混,放在桌面中央,形成抽牌堆。
- 将城堡标记洗混,随机抽取1张正面朝上与1张背面朝上,放在桌面中央。
-【5+人游戏】:额外抽取1张正面朝上放在桌面中央。
每名玩家布置:
- 选择一张旗帜牌,放在自己面前靠左的位置。
- 抓5张公共牌作为起始手牌。
- 获得1个王冠标记。
以阅读规则的玩家为起始玩家开始游戏。
## 游戏目标
有玩家获得7个王冠时,游戏结束。分数最高的玩家赢得游戏胜利。
## 游戏流程
游戏按轮进行,每轮进行以下流程:
- **出牌阶段**:玩家轮流选择卡牌打出或盖放。
- **翻牌阶段**:玩家轮流选择翻开一张自己的盖放牌。
- **结算阶段**:检查所有玩家的得分情况,决定胜者。
- **清理阶段**:清理战场,准备下一轮游戏。
### 出牌阶段
从起始玩家开始,每名玩家轮流进行出牌。
出牌时,可从手牌打出一张牌,或者选择跳过。
若玩家选择跳过,则会结束该玩家的本次出牌阶段。
出牌时,按照卡牌描述的方式打出牌:
- 将打出的卡牌放到目标玩家面前,放在其他打出卡牌的右侧。
- 卡牌默认打出给自己,除非特殊说明。
- 如果卡牌标记了盖放,则这张牌必须盖放打出,否则必须正面打出。
### 翻牌阶段
从起始玩家开始,每名玩家轮流进行翻牌。
在玩家面前有盖放牌时,玩家必须选择其中一张翻开。
否则,结束该玩家的本次翻牌阶段。
### 结算阶段
将牌面点数和领袖牌点数全部加总,作为玩家本轮的总战力。
按总战力从高到低的顺序,从桌面中央获得正面或背面朝上的城堡标记,直到拿空。
若有玩家打平,则行动顺序更靠前的玩家赢得胜利。
每当有玩家赢得正面朝上的城堡标记,若其他玩家拥有同色城堡,则他们必须为其每个同色城堡选择:
- 支付1个王冠标记给胜者;
- 从胜者处获得1个王冠标记;将城堡交给胜者。
由本轮胜者决定其他玩家做选择的顺序。
最后,按设置的方式补充中央的城堡标记,然后开始下一轮游戏。
### 清理阶段
将本回合打出的所有公共牌放到弃牌堆里。
如果旗帜或其他牌上有骷髅或号角标记,将这些标记放回供应堆。
然后,每名玩家抓3张公共牌开始下一轮。
## 抓牌
当玩家抓牌时,如果抓牌堆为空,则将对应的弃牌堆洗回牌堆再继续。
## 号角与骷髅
- 号角可以放在公共牌上,也可以放在旗帜上。
- 放在旗帜上时,为自己每张公共牌提供+1战力。
- 放在公共牌上时,为这张牌增加1倍战力。
- 骷髅与号角类似,但是扣减1倍战力而非增加。
- 骷髅与号角相互抵消,同类效果可叠加。战力可能会变为负数。
+29
View File
@@ -0,0 +1,29 @@
# 魔烬纷争 - 势力扩展
## 游戏概述
势力扩展为魔烬纷争增加了不同的专属势力卡牌。
## 游戏配件
势力配件:10 组
- 领袖牌:每个势力1张
- 势力牌:每个势力15张
- 金币标记(商人势力):6个
## 游戏布置
对玩家布置进行以下调整:
- 选择一套势力配件获得。
- 将势力牌洗混放在旗帜牌下方。左侧留出空间作为势力牌弃牌堆。
- 将领袖牌放在旗帜牌左侧。
- 额外抓2张势力牌,然后选择两张牌弃掉。
领袖牌规则:
- 效果总是生效,战力计入总点数。可以获得号角或骷髅。
- 不计入旗帜的卡牌数目,战斗结束时只清理号角/骷髅标记不清理牌。
势力牌规则:
- 每轮清理阶段抓牌时,改为抓 2 张公共牌和 1 张势力牌。
- 因卡牌效果抓牌时,可以选择抓公共牌或者势力牌。
- 势力牌弃掉时,放入独立的势力牌弃牌堆。势力牌堆为空而选择抓势力牌时,将其洗回势力牌堆。
@@ -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>
+157
View File
@@ -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)]);
}
}
+142
View File
@@ -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;
}
}
}
+137
View File
@@ -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);
}
}
+126
View File
@@ -0,0 +1,126 @@
using Game.TicTacToe;
using OECS;
using System.Text;
namespace Game.TicTacToe.Tests;
internal static class TestHelpers
{
/// <summary>
/// Creates a fresh TicTacToe world with 9 empty cells, GameState singleton, and WinCheckSystem.
/// </summary>
public static (World World, SystemGroup Group) SetupGame()
{
var world = new World();
var group = new SystemGroup(world);
group.Add(new WinCheckSystem());
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++)
world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c });
world.SetSingleton(new GameState
{
CurrentPlayer = Player.X,
Status = GameStatus.Playing,
MoveCount = 0
});
world.PostChanges();
return (world, group);
}
/// <summary>
/// Enqueues and runs a sequence of moves.
/// </summary>
public static void PlayMoves(World world, SystemGroup group, params (int Row, int Col)[] moves)
{
foreach (var (row, col) in moves)
{
world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col });
group.RunLogical();
}
}
/// <summary>
/// Returns a human-readable textual snapshot of the world state.
/// </summary>
public static string SnapshotWorld(World world)
{
var sb = new StringBuilder();
var state = world.ReadSingleton<GameState>();
var statusText = state.Status switch
{
GameStatus.Playing => $"{(state.CurrentPlayer == Player.X ? 'X' : 'O')}'s turn, {state.MoveCount} moves",
GameStatus.XWon => "XWon",
GameStatus.OWon => "OWon",
GameStatus.Draw => "Draw",
_ => "Unknown"
};
sb.AppendLine($"GameState: {statusText}");
var grid = new char?[3, 3];
foreach (var it in world.Select<Cell, Mark>())
grid[it.Val1.Row, it.Val1.Col] =
it.Val2.Player == Player.X ? 'X' : 'O';
int totalCells = 0;
int markedCells = 0;
for (int r = 0; r < 3; r++)
{
for (int c = 0; c < 3; c++)
{
totalCells++;
var mark = grid[r, c];
if (mark.HasValue)
{
markedCells++;
sb.AppendLine($" ({r},{c}): {mark.Value}");
}
else
{
sb.AppendLine($" ({r},{c}): .");
}
}
}
sb.AppendLine($"Cells: {totalCells} total, {markedCells} marked");
return sb.ToString().TrimEnd();
}
/// <summary>
/// Returns the (Row, Col) of all empty cells.
/// </summary>
public static List<(int Row, int Col)> GetEmptyCells(World world)
{
var empty = new List<(int, int)>();
foreach (var it in world.Select(new Query<Cell>().Without<Mark>()))
empty.Add((it.Val1.Row, it.Val1.Col));
return empty;
}
/// <summary>
/// Checks whether placing a mark at (row, col) for the given player would win the game.
/// </summary>
public static bool WouldWin(World world, int row, int col, Player player)
{
var grid = new Player[3, 3];
foreach (var it in world.Select<Cell, Mark>())
grid[it.Val1.Row, it.Val1.Col] = it.Val2.Player;
grid[row, col] = player;
for (int r = 0; r < 3; r++)
if (grid[r, 0] == player && grid[r, 1] == player && grid[r, 2] == player)
return true;
for (int c = 0; c < 3; c++)
if (grid[0, c] == player && grid[1, c] == player && grid[2, c] == player)
return true;
if (grid[0, 0] == player && grid[1, 1] == player && grid[2, 2] == player)
return true;
if (grid[0, 2] == player && grid[1, 1] == player && grid[2, 0] == player)
return true;
return false;
}
}
@@ -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,4 +1,4 @@
namespace TicTacToe; namespace Game.TicTacToe;
public enum GameStatus : byte public enum GameStatus : byte
{ {
@@ -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,27 +8,18 @@ namespace TicTacToe;
/// </summary> /// </summary>
public class WinCheckSystem : ISystem public class WinCheckSystem : ISystem
{ {
public QueryDescriptor Query { get; } public void RunImpl(World world)
public WinCheckSystem(World world)
{ {
Query = world.Query().With<Cell>().With<Mark>().Build(); if (world.ReadSingleton<GameState>().Status != GameStatus.Playing)
}
public void Run(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Status != GameStatus.Playing)
return; return;
// Build a 3×3 grid of marks using the iterator API. ref var state = ref world.GetSingleton<GameState>();
var grid = new Player[3, 3];
using var iter = world.Select<Cell, Mark>(Query); // Build a 3×3 grid of marks.
while (iter.MoveNext()) var grid = new Player[3, 3];
foreach (var iter in world.Select<Cell, Mark>())
{ {
grid[iter.Current1.Row, iter.Current1.Col] = iter.Current2.Player; grid[iter.Val1.Row, iter.Val1.Col] = iter.Val2.Player;
} }
// Check rows. // Check rows.
@@ -36,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;
} }
} }
@@ -46,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;
} }
} }
@@ -54,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)
@@ -82,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);
} }
} }
+23
View File
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<RootNamespace>Game.TicTacToe</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OECS\OECS.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OECS.SourceGen\OECS.SourceGen.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<None Update="Data\board.csv">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+54
View File
@@ -0,0 +1,54 @@
using OECS;
namespace OECS.PlayTest;
/// <summary>
/// Abstract agent that picks the action with the highest score.
/// When <see cref="ScoreAction"/> returns 0 for all actions (the default),
/// the agent behaves as a uniform random agent over legal actions.
/// Ties are broken randomly.
/// </summary>
public abstract class GreedyAgent<TDecision> : IAgent<TDecision>
{
private static readonly Random _rng = new();
/// <summary>
/// Returns the list of currently legal actions.
/// </summary>
protected abstract List<TDecision> GetLegalActions(World world);
/// <summary>
/// Scores an action. Defaults to 0 (uniform random).
/// </summary>
protected virtual float ScoreAction(World world, TDecision action) => 0f;
public TDecision Decide(World world)
{
var actions = GetLegalActions(world);
if (actions.Count == 0)
throw new InvalidOperationException(
$"{GetType().Name}: no legal actions available");
if (actions.Count == 1)
return actions[0];
float bestScore = float.MinValue;
var bestActions = new List<TDecision>();
foreach (var action in actions)
{
float score = ScoreAction(world, action);
if (score > bestScore)
{
bestScore = score;
bestActions.Clear();
bestActions.Add(action);
}
else if (score == bestScore)
{
bestActions.Add(action);
}
}
return bestActions[_rng.Next(bestActions.Count)];
}
}
+11
View File
@@ -0,0 +1,11 @@
using OECS;
namespace OECS.PlayTest;
/// <summary>
/// An AI player that inspects the world and returns a decision.
/// </summary>
public interface IAgent<TDecision>
{
TDecision Decide(World world);
}
+33
View File
@@ -0,0 +1,33 @@
namespace OECS.PlayTest;
/// <summary>
/// A pool of agents selected by weight. Higher weight = more likely to be picked.
/// </summary>
public class WeightedAgentPool<TDecision>
{
private readonly List<(IAgent<TDecision> Agent, int Weight)> _agents = new();
private int _totalWeight;
private static readonly Random _rng = new();
public void Add(IAgent<TDecision> agent, int weight)
{
_agents.Add((agent, weight));
_totalWeight += weight;
}
public IAgent<TDecision> Pick()
{
if (_agents.Count == 0)
throw new InvalidOperationException("WeightedAgentPool is empty");
int roll = _rng.Next(_totalWeight);
int cumulative = 0;
foreach (var (agent, weight) in _agents)
{
cumulative += weight;
if (roll < cumulative)
return agent;
}
return _agents[^1].Agent;
}
}
+12
View File
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>OECS.PlayTest</RootNamespace>
<AssemblyName>OECS.PlayTest</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OECS\OECS.csproj" />
</ItemGroup>
</Project>
+95
View File
@@ -0,0 +1,95 @@
using OECS;
using R3;
namespace OECS.PlayTest;
/// <summary>
/// Captures all entity changes from the World's observable API and produces
/// a compact text log on demand. Call <see cref="FormatWith{T}"/> to enrich
/// component entries with human-readable values.
/// </summary>
public sealed class ObservableCapture : IDisposable
{
private readonly World _world;
private readonly IDisposable _subscription;
private readonly List<CapturedEvent> _events = new();
private readonly Dictionary<Type, Func<World, Entity, string>> _formatters = new();
public ObservableCapture(World world)
{
_world = world;
_subscription = world.ObserveEntityChanges().Subscribe(OnChange);
}
/// <summary>
/// Registers a formatter for component type <typeparamref name="T"/>.
/// When a change for this type is captured, the formatted value is
/// included in the log output.
/// </summary>
public void FormatWith<T>(Func<T, string> formatter) where T : struct
{
_formatters[typeof(T)] = (w, e) =>
{
var value = w.ReadComponent<T>(e);
return formatter(value);
};
}
/// <summary>
/// Returns the captured log as a single string with one line per change.
/// </summary>
public string GetLog() => string.Join(Environment.NewLine, GetLogLines());
/// <summary>
/// Returns the captured log as a list of lines, one per change.
/// </summary>
public List<string> GetLogLines()
{
var lines = new List<string>(_events.Count);
foreach (var evt in _events)
{
var change = evt.Change;
var kind = change.Kind.ToString();
if (change.ComponentType != null)
{
var typeName = change.ComponentType.Name;
if (evt.FormattedValue != null)
lines.Add($"{kind} {typeName} = {evt.FormattedValue} on {change.Entity}");
else
lines.Add($"{kind} {typeName} on {change.Entity}");
}
else
{
lines.Add($"{kind} on {change.Entity}");
}
}
return lines;
}
public void Dispose() => _subscription.Dispose();
private void OnChange(EntityChange change)
{
string? formattedValue = null;
if (change.ComponentType != null
&& change.Kind != ChangeKind.ComponentRemoved
&& change.Kind != ChangeKind.EntityRemoved
&& change.Kind != ChangeKind.RelationshipReordered
&& _formatters.TryGetValue(change.ComponentType, out var formatter))
{
formattedValue = formatter(_world, change.Entity);
}
_events.Add(new CapturedEvent(change, formattedValue));
}
private readonly struct CapturedEvent
{
public readonly EntityChange Change;
public readonly string? FormattedValue;
public CapturedEvent(EntityChange change, string? formattedValue)
{
Change = change;
FormattedValue = formattedValue;
}
}
}
+57
View File
@@ -0,0 +1,57 @@
using System.Text;
namespace OECS.PlayTest;
/// <summary>
/// Builds a sectioned play log with header, sections, and final snapshot,
/// then saves it to a .playlog file.
/// </summary>
public class PlayLog
{
public string Header { get; set; } = "";
public string FinalSnapshot { get; set; } = "";
private readonly List<(string Title, List<string> Lines)> _sections = new();
/// <summary>
/// Adds a section with the given title and lines.
/// </summary>
public void AddSection(string title, IEnumerable<string> lines)
{
_sections.Add((title, lines.ToList()));
}
/// <summary>
/// Builds the complete play log text.
/// </summary>
public string Build()
{
var sb = new StringBuilder();
sb.AppendLine(Header);
sb.AppendLine(new string('=', Header.Length));
sb.AppendLine();
foreach (var (title, lines) in _sections)
{
sb.AppendLine($"--- {title} ---");
foreach (var line in lines)
sb.AppendLine($" {line}");
sb.AppendLine();
}
sb.AppendLine("--- Final State ---");
sb.AppendLine(FinalSnapshot);
return sb.ToString();
}
/// <summary>
/// Saves the play log to AppContext.BaseDirectory/playlogs/<paramref name="filename"/>
/// and returns the full path.
/// </summary>
public string SaveTo(string filename)
{
var dir = Path.Combine(AppContext.BaseDirectory, "playlogs");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, filename);
File.WriteAllText(path, Build());
return path;
}
}
@@ -0,0 +1,193 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Collections.Immutable;
using System.Text;
namespace OECS.SourceGen;
/// <summary>
/// Incremental source generator that discovers all component types used with
/// <see cref="OECS.World"/> and generates a strongly-typed registry so that
/// <see cref="OECS.WorldSerializer"/> can save/load without reflection.
/// </summary>
[Generator]
public class ComponentDiscoveryGenerator : IIncrementalGenerator
{
private static readonly HashSet<string> _componentApiMethods = new()
{
// Component management
"AddComponent",
"RemoveComponent",
"HasComponent",
"GetComponent",
"TryGetComponent",
"ReadComponent",
"MarkModified",
// Singletons
"SetSingleton",
"GetSingleton",
"ReadSingleton",
"HasSingleton",
"RemoveSingleton",
// Observability
"ObserveComponentChanges",
// Query building
"With",
"Without",
// Iteration and relationships
"Select",
"GetSources",
};
private static readonly HashSet<string> _singletonMethods = new()
{
"SetSingleton",
"GetSingleton",
"ReadSingleton",
"HasSingleton",
"RemoveSingleton",
};
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Collect all generic method invocations that reference component types.
var invocations = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: IsCandidateInvocation,
transform: ExtractComponentType)
.Where(t => t.FullyQualifiedName != null)
.Select((t, _) => (t.FullyQualifiedName!, t.AssemblyQualifiedName!, t.IsRelationship, t.IsSingleton));
var allTypes = invocations.Collect();
context.RegisterSourceOutput(allTypes, GenerateRegistry);
}
private static bool IsCandidateInvocation(SyntaxNode node, CancellationToken _)
{
if (node is not InvocationExpressionSyntax invocation)
return false;
if (invocation.Expression is MemberAccessExpressionSyntax memberAccess)
{
if (memberAccess.Name is GenericNameSyntax genericName)
{
return _componentApiMethods.Contains(genericName.Identifier.Text);
}
}
return false;
}
private static (string? FullyQualifiedName, string? AssemblyQualifiedName, bool IsRelationship, bool IsSingleton) ExtractComponentType(
GeneratorSyntaxContext ctx, CancellationToken _)
{
if (ctx.Node is not InvocationExpressionSyntax invocation)
return default;
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
return default;
if (memberAccess.Name is not GenericNameSyntax genericName)
return default;
var typeArg = genericName.TypeArgumentList.Arguments.FirstOrDefault();
if (typeArg == null)
return default;
var symbolInfo = ctx.SemanticModel.GetSymbolInfo(typeArg);
if (symbolInfo.Symbol is not INamedTypeSymbol typeSymbol)
return default;
// Only public struct types are valid components.
if (!typeSymbol.IsValueType || typeSymbol.IsAbstract)
return default;
if (typeSymbol.DeclaredAccessibility != Accessibility.Public)
return default;
var fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Assembly-qualified name for stable serialization (without assembly version).
var aqn = typeSymbol.ContainingAssembly != null
? $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted))}, {typeSymbol.ContainingAssembly.Name}"
: fqn;
bool isRelationship = typeSymbol.AllInterfaces.Any(i =>
i.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::OECS.IRelationship");
bool isSingleton = _singletonMethods.Contains(genericName.Identifier.Text);
return (fqn, aqn, isRelationship, isSingleton);
}
private static void GenerateRegistry(SourceProductionContext context,
ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship, bool IsSingleton)> invocations)
{
// Collect unique types by fully-qualified name, deduplicating.
var typeMap = new Dictionary<string, (string Fqn, string Aqn, bool IsRel, bool IsSingleton)>();
foreach (var t in invocations)
{
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)
return;
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("using System.Runtime.CompilerServices;");
sb.AppendLine("using MessagePack;");
sb.AppendLine();
sb.AppendLine("namespace OECS");
sb.AppendLine("{");
sb.AppendLine(" file static class ComponentRegistryInitializer");
sb.AppendLine(" {");
sb.AppendLine(" [ModuleInitializer]");
sb.AppendLine(" public static void Initialize()");
sb.AppendLine(" {");
sb.AppendLine(" ComponentRegistry.Register(new ComponentDescriptor[]");
sb.AppendLine(" {");
bool first = true;
foreach (var (fqn, aqn, isRel, isSingleton) in typeMap.Values.OrderBy(t => t.Fqn))
{
if (!first)
sb.AppendLine(",");
first = false;
sb.AppendLine($" new ComponentDescriptor(");
sb.AppendLine($" typeName: \"{aqn}\",");
sb.AppendLine($" type: typeof({fqn}),");
sb.AppendLine($" serialize: obj => MessagePackSerializer.Serialize(({fqn})obj),");
sb.AppendLine($" deserializeAndAdd: (world, entity, data) =>");
sb.AppendLine($" world.AddComponent(entity, MessagePackSerializer.Deserialize<{fqn}>(data)),");
sb.AppendLine($" deserialize: data => MessagePackSerializer.Deserialize<{fqn}>(data),");
sb.AppendLine($" isSingleton: {(isSingleton ? "true" : "false")}");
sb.Append(" )");
}
sb.AppendLine();
sb.AppendLine(" });");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine("}");
context.AddSource("ComponentRegistry.g.cs", sb.ToString());
}
}
+22
View File
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<RootNamespace>OECS.SourceGen</RootNamespace>
<AssemblyName>OECS.SourceGen</AssemblyName>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IsRoslynComponent>true</IsRoslynComponent>
<IncludeBuildOutput>false</IncludeBuildOutput>
<DevelopmentDependency>true</DevelopmentDependency>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
</Project>
@@ -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();
@@ -249,16 +245,13 @@ public class CommandTests
private readonly World _world; private readonly World _world;
private readonly SpawnCommand _command; private readonly SpawnCommand _command;
public QueryDescriptor Query { get; }
public CommandEnqueueSystem(World world, SpawnCommand command) public CommandEnqueueSystem(World world, SpawnCommand command)
{ {
_world = world; _world = world;
_command = command; _command = command;
Query = world.Query().With<TestPosition>().Build();
} }
public void Run(World world) public void RunImpl(World world)
{ {
_world.Commands.Enqueue(_command); _world.Commands.Enqueue(_command);
} }
@@ -268,19 +261,10 @@ public class CommandTests
{ {
public int SeenCount { get; private set; } public int SeenCount { get; private set; }
public QueryDescriptor Query { get; } public void RunImpl(World world)
public EntityCountObserver(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;
}
}
} }
+235
View File
@@ -0,0 +1,235 @@
using FluentAssertions;
using Xunit;
namespace OECS.Tests;
public class InterruptTests
{
// ── Test types ──
private record struct TestInterrupt : IInterrupt
{
public string Message;
}
[MessagePack.MessagePackObject]
private struct TestHandler : IInterruptHandlerCommand<TestInterrupt>
{
[MessagePack.Key(0)]
public bool ShouldResolve;
public bool TryResolve(TestInterrupt interrupt)
{
return ShouldResolve;
}
}
private record struct AnotherInterrupt : IInterrupt
{
public int Value;
}
[MessagePack.MessagePackObject]
private struct AnotherHandler : IInterruptHandlerCommand<AnotherInterrupt>
{
[MessagePack.Key(0)]
public bool ShouldResolve;
public bool TryResolve(AnotherInterrupt interrupt)
{
return ShouldResolve;
}
}
// ── Issue and resolve ──
[Fact]
public void Interrupt_blocks_next_tick()
{
var world = new World();
var group = new SystemGroup(world);
var systemRan = false;
group.Add(new TestSystem(() => systemRan = true));
// Issue an interrupt.
world.Interrupt(new TestInterrupt { Message = "hello" });
// Run a tick — systems should be skipped.
group.RunLogical();
systemRan.Should().BeFalse();
}
[Fact]
public void Interrupt_resolved_by_handler_unblocks_world()
{
var world = new World();
var group = new SystemGroup(world);
var systemRan = false;
group.Add(new TestSystem(() => systemRan = true));
// Issue an interrupt.
world.Interrupt(new TestInterrupt { Message = "hello" });
group.RunLogical();
systemRan.Should().BeFalse();
// Enqueue a handler that resolves it.
world.Commands.Enqueue(new TestHandler { ShouldResolve = true });
group.RunLogical();
systemRan.Should().BeTrue();
}
[Fact]
public void Interrupt_handler_rejects_leaves_interrupt_pending()
{
var world = new World();
var group = new SystemGroup(world);
var systemRan = false;
group.Add(new TestSystem(() => systemRan = true));
world.Interrupt(new TestInterrupt { Message = "hello" });
group.RunLogical();
systemRan.Should().BeFalse();
// Handler rejects — interrupt stays pending.
world.Commands.Enqueue(new TestHandler { ShouldResolve = false });
group.RunLogical();
systemRan.Should().BeFalse();
// Second handler accepts.
world.Commands.Enqueue(new TestHandler { ShouldResolve = true });
group.RunLogical();
systemRan.Should().BeTrue();
}
[Fact]
public void HasInterrupt_returns_true_for_pending_interrupt()
{
var world = new World();
var group = new SystemGroup(world);
world.HasInterrupt<TestInterrupt>().Should().BeFalse();
world.Interrupt(new TestInterrupt { Message = "hello" });
world.HasInterrupt<TestInterrupt>().Should().BeTrue();
world.Commands.Enqueue(new TestHandler { ShouldResolve = true });
group.RunLogical();
world.HasInterrupt<TestInterrupt>().Should().BeFalse();
}
[Fact]
public void Duplicate_interrupt_type_throws()
{
var world = new World();
var group = new SystemGroup(world);
world.Interrupt(new TestInterrupt { Message = "first" });
var act = () => world.Interrupt(new TestInterrupt { Message = "second" });
act.Should().Throw<InvalidOperationException>()
.WithMessage("*TestInterrupt*");
}
[Fact]
public void Multiple_interrupt_types_independent()
{
var world = new World();
var group = new SystemGroup(world);
world.Interrupt(new TestInterrupt { Message = "a" });
world.Interrupt(new AnotherInterrupt { Value = 42 });
// Resolve only one.
world.Commands.Enqueue(new TestHandler { ShouldResolve = true });
group.RunLogical();
// TestInterrupt resolved, AnotherInterrupt still pending.
world.HasInterrupt<TestInterrupt>().Should().BeFalse();
world.HasInterrupt<AnotherInterrupt>().Should().BeTrue();
// Resolve the other.
world.Commands.Enqueue(new AnotherHandler { ShouldResolve = true });
group.RunLogical();
world.HasInterrupt<AnotherInterrupt>().Should().BeFalse();
}
[Fact]
public void Handler_receives_correct_interrupt_data()
{
var world = new World();
var group = new SystemGroup(world);
world.Interrupt(new TestInterrupt { Message = "hello" });
// Enqueue handler and run — the default Execute calls TryResolve
// with the interrupt data.
world.Commands.Enqueue(new TestHandler { ShouldResolve = true });
group.RunLogical();
// Interrupt was resolved — the TryResolve received the correct data.
world.HasInterrupt<TestInterrupt>().Should().BeFalse();
}
[Fact]
public void Interrupt_without_system_group_is_noop()
{
var world = new World();
// No SystemGroup — interrupt is silently ignored.
world.Interrupt(new TestInterrupt { Message = "hello" });
world.HasInterrupt<TestInterrupt>().Should().BeFalse();
}
[Fact]
public void Commands_still_execute_when_blocked()
{
var world = new World();
var group = new SystemGroup(world);
var commandExecuted = false;
var handler = new TestHandler { ShouldResolve = true };
world.Interrupt(new TestInterrupt { Message = "hello" });
// Enqueue both a handler and a regular command.
world.Commands.Enqueue(new TestHandler { ShouldResolve = true });
world.Commands.Enqueue(new SetFlagCommand(() => commandExecuted = true));
group.RunLogical();
// Both should have executed.
world.HasInterrupt<TestInterrupt>().Should().BeFalse();
commandExecuted.Should().BeTrue();
}
// ── Helpers ──
private class TestSystem : ISystem
{
private readonly Action _action;
public TestSystem(Action action) => _action = action;
public void RunImpl(World world) => _action();
}
[MessagePack.MessagePackObject]
private struct SetFlagCommand : ICommand
{
private Action? _action;
[MessagePack.IgnoreMember]
public Action Action
{
get => _action!;
set => _action = value;
}
public SetFlagCommand(Action action) => _action = action;
public void Execute(World world) => _action?.Invoke();
}
}

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