oecs-sharp/.agents/skills/testing-games/SKILL.md

5.4 KiB

name description
testing-games 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:

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

[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:

[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:

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:

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:

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:

[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

dotnet test "E:/projects/oecs-sharp/Game.YourGame.Tests/Game.YourGame.Tests.csproj"

To run only playtests:

dotnet test ... --filter "FullyQualifiedName~PlayTests"