Recursive AI

Test a game from C#

All onegame recipes

Write xUnit tests for a C# game with onegame’s test API: drive a headless game by id and command, run scenario files, and prove determinism with RunTwice, SnapshotRestore and Replay.

RecursiveAI.OneGame.Testing runs the game headless inside the test process, through the same operations as scenarios and live control. onegame test runs these tests together with the scenarios.

The test project

Put it in tests/ inside the game’s folder. onegame test runs every project under the game folder whose name contains “Test”, with dotnet test --project.

tests/Orchard.Tests.csproj

XML
<Project Sdk="Microsoft.NET.Sdk">
 
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <OutputType>Exe</OutputType>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
 
  <ItemGroup>
    <ProjectReference Include="../orchard.csproj" />
    <PackageReference Include="RecursiveAI.OneGame.Testing" Version="1.1.2" />
    <PackageReference Include="RecursiveAI.OneGame.Live" Version="1.1.2" />
    <PackageReference Include="xunit.v3" Version="4.0.2" />
    <Using Include="Xunit" />
  </ItemGroup>
 
  <ItemGroup>
    <None Include="../scenarios/**" LinkBase="scenarios" CopyToOutputDirectory="PreserveNewest" />
  </ItemGroup>
 
</Project>

Two more files in the game’s folder. The game’s project must leave the tests out of its own compile:

XML
<Project Sdk="RecursiveAI.OneGame.Sdk/1.1.2">
 
  <PropertyGroup>
    <AssemblyName>Orchard</AssemblyName>
    <RootNamespace>Orchard</RootNamespace>
  </PropertyGroup>
 
  <ItemGroup>
    <!-- The tests are their own project. -->
    <Compile Remove="tests/**" />
  </ItemGroup>
 
</Project>

And a global.json that selects the Microsoft.Testing.Platform runner, which dotnet test --project needs on the .NET 10 SDK:

JSON
{
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}

Without it, onegame test runs the scenarios, then the test project fails with MSBUILD : error MSB1001: Unknown switch. (Switch: --project), and the run exits 1.

The tests

tests/TreeTests.cs, for the apple tree from Getting started:

C#
using OneGame.Testing;
 
namespace Orchard.Tests;
 
public class TreeTests
{
    private static readonly TestOptions Options = new() { Width = 640, Height = 360, Seed = 7 };
 
    // Pick one apple every 40 frames.
    private static void PickNow(TestClient client, int frame)
    {
        if (frame % 40 == 39)
        {
            client.Send("Tree", "Pick", "1");
        }
    }
 
    [Fact]
    public void PickingTakesAnAppleFromTheTree()
    {
        using var cluster = TestCluster.Start(new OrchardGame(), Options);
        var client = cluster.Client;
 
        client.Step(300);
        var before = int.Parse(client.Get("Tree", "Apples"));
        client.Click("pick").Step(1);
 
        Assert.Equal(before - 1, int.Parse(client.Get("Tree", "Apples")));
        Assert.Equal("1", client.Get("Tree", "Picked"));
    }
 
    [Fact]
    public void ThePickApplesScenarioPasses()
    {
        using var cluster = TestCluster.Start(new OrchardGame(), Options);
        var result = cluster.Run("scenarios/pick-apples.yaml");
        Assert.True(result.Passed, result.ToString());
    }
 
    [Fact]
    public void TheSameRunTwiceGivesTheSameHashEveryFrame() =>
        Determinism.RunTwice(() => new OrchardGame(), PickNow, 600, Options).ThrowIfDifferent();
 
    [Fact]
    public void ASnapshotRestoredHalfwayFinishesOnTheSameHashes() =>
        Determinism.SnapshotRestore(() => new OrchardGame(), PickNow, 600, splitFrame: 300, Options).ThrowIfDifferent();
 
    [Fact]
    public void ARecordingReplaysToTheSameHashes() =>
        Determinism.Replay(() => new OrchardGame(), PickNow, 600, Options).ThrowIfDifferent();
}
Terminal
onegame test
Text
PASS pick-apples (40 ms, tick 302, hash 9a447aae324c764b)
1 passed, 0 failed
Running tests from /tmp/games/orchard/tests/bin/Debug/net10.0/Orchard.Tests.dll (net10.0|x64)
/tmp/games/orchard/tests/bin/Debug/net10.0/Orchard.Tests.dll (net10.0|x64) passed (370ms)
 
Test run summary: Passed!
  total: 5
  failed: 0
  succeeded: 5
  skipped: 0
  duration: 509ms
unit tests: 5 passed, 0 failed, 0 skipped
  • TestCluster.Start(game, options) starts one headless node. cluster.Client clicks by id (Click), presses keys (Press), types (Type), sends commands as a player would (Send, run on the next tick), steps frames (Step) and reads one synced property as text (Get). Call(op, json) runs any live-control operation, and Capture renders a frame.
  • cluster.Run(path) runs a scenario file against the game and returns its result.
  • Determinism.RunTwice runs the script in two fresh games and compares the state hash after every frame. SnapshotRestore takes a snapshot at splitFrame, restores it into a new game and checks the rest of the run lands on the same hashes. Replay records the run and replays it. ThrowIfDifferent() fails the test with the first frame that differs and both hashes.
  • TestOptions.Seed fixes the world’s seed; Width, Height and Scale set the window, and Gpu set to off renders captures on the CPU.

Good to know

  • RecursiveAI.OneGame.Live is the development tools package (scenario runs, captures, replays) that a Debug build of the game carries and the test API drives.
  • onegame test --no-unit skips the C# tests; dotnet test --project tests/Orchard.Tests.csproj runs only them.
  • To test servers and clients as separate processes, TestCluster.Launch(spec) starts a cluster from the same spec as onegame cluster: see Client/server sync with a dedicated server.