Engine-Side Test Harness Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Stand up a sim-parity test lane that runs the same scenarios under plain dotnet test and under godot --headless with GdUnit4Net, proving the existing pure-C# sim behaves identically when hosted by Godot 4.6.2 mono.

Architecture: A new shared Muscleman.Sim.TestFixtures library defines scenarios as data (Seed delegate + Inputs list + ExpectedEvents builder). Two test lanes — xUnit in Muscleman.Sim.Tests and GdUnit4 in a new engine/ Godot project — both consume Scenarios.All and assert equivalence against the same expected output.

Tech Stack: C# / .NET 9 · xUnit + FluentAssertions (existing) · Godot 4.6.2-stable mono · GdUnit4 (GDScript addon for headless runner) · gdUnit4.api NuGet (C# test API).


Spec reference

This plan implements docs/superpowers/specs/2026-05-13-engine-test-harness-design.md. Read §2 (architecture), §4 (components), §8 (definition of done) before starting.

Locking in spec open questions

The spec defers three details (§9). This plan locks them in:

  1. Scenario.Seed shape: Returns a richer ScenarioContext (not just int) so ExpectedEvents can reference both PlayerId and named auxiliary entities like "box". ExpectedEvents becomes Func<ScenarioContext, IReadOnlyList<SimEvent>> (lazy, evaluated after Seed runs so entity ids are known).
  2. GdUnit4Net [TestCase] parameterisation: Not relied upon. One [TestCase] method per scenario in the engine project, each method invoking a shared RunScenarioByName(string) helper. Simpler and avoids type-mapping headaches.
  3. Headless CLI flag set: Pinned in Task 9 against the version chosen in Task 7, with command shown in full.

File structure

muscleman-godot4/
├── Muscleman.sln                                    MODIFIED — adds 2 new projects
├── Makefile                                         NEW
├── .gitignore                                       MODIFIED — adds Godot transients
├── src/
│   ├── Muscleman.Sim.TestFixtures/                  NEW project
│   │   ├── Muscleman.Sim.TestFixtures.csproj
│   │   ├── Scenario.cs                              (Scenario record + ScenarioContext)
│   │   ├── ScenarioRunner.cs                        (runs a Scenario, returns SimEvent list)
│   │   └── Scenarios.cs                             (static catalog: All + 3 starter scenarios)
│   └── Muscleman.Sim.Tests/
│       └── ParityTests.cs                           NEW — xUnit theory over Scenarios.All
└── engine/                                          NEW Godot project root
    ├── project.godot                                (manifest, Godot 4.6 mono / .NET 9)
    ├── Muscleman.Engine.Tests.csproj                (Godot-generated C# project)
    ├── icon.svg                                     (Godot stub, may be auto-created)
    ├── addons/gdUnit4/                              (committed addon for headless runner)
    ├── tests/
    │   └── ParityTests.cs                           (GdUnit4 test suite — one method per scenario)
    └── .gitignore                                   (.godot/, .mono/, .import/, *.uid, etc.)

Decomposition rationale: Scenario, ScenarioRunner, and Scenarios are split because they have distinct responsibilities (type, executor, data). They live in one project because they always change together when a scenario is added or its expectations are revised. Tests live in their respective harness projects.


Task 1: Bootstrap Muscleman.Sim.TestFixtures project

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim.TestFixtures/Muscleman.Sim.TestFixtures.csproj
  • Modify: muscleman-godot4/Muscleman.sln (add new project entry)

  • Step 1: Create the csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\Muscleman.Core\Muscleman.Core.csproj" />
    <ProjectReference Include="..\Muscleman.Sim\Muscleman.Sim.csproj" />
  </ItemGroup>

</Project>
  • Step 2: Add the project to the solution

Run from muscleman-godot4/:

dotnet sln Muscleman.sln add src/Muscleman.Sim.TestFixtures/Muscleman.Sim.TestFixtures.csproj

Expected output: Project ... added to the solution.

  • Step 3: Verify it builds

Run:

dotnet build muscleman-godot4/Muscleman.sln

Expected: build succeeds with 0 errors. The new project compiles (empty but valid).

  • Step 4: Commit
git add muscleman-godot4/src/Muscleman.Sim.TestFixtures/ muscleman-godot4/Muscleman.sln
git commit -m "test-fixtures: bootstrap Muscleman.Sim.TestFixtures project"

Task 2: Define Scenario and ScenarioContext types (TDD)

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim.TestFixtures/Scenario.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs (temporary, removed after Task 5)
  • Modify: muscleman-godot4/src/Muscleman.Sim.Tests/Muscleman.Sim.Tests.csproj (add ProjectReference to TestFixtures)

  • Step 1: Add a ProjectReference from Tests to TestFixtures

In muscleman-godot4/src/Muscleman.Sim.Tests/Muscleman.Sim.Tests.csproj, add inside the existing ItemGroup containing ProjectReference elements:

<ProjectReference Include="..\Muscleman.Sim.TestFixtures\Muscleman.Sim.TestFixtures.csproj" />
  • Step 2: Write the failing test for the Scenario type shape

Create muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs:

using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Muscleman.Core.Input;
using Muscleman.Sim;
using Muscleman.Sim.TestFixtures;
using FluentAssertions;
using Xunit;

namespace Muscleman.Sim.Tests;

public class ScenarioTypeTests
{
    [Fact]
    public void Scenario_holds_name_seed_inputs_and_expected_events_builder()
    {
        var s = new Scenario(
            Name: "trivial",
            Seed: w =>
            {
                var pid = w.SpawnEmpty(new GridPos(0, 0));
                return new ScenarioContext(w, pid, new Dictionary<string, int>());
            },
            Inputs: new List<Input> { new Input.Wait() },
            ExpectedEvents: _ => new List<SimEvent>());

        s.Name.Should().Be("trivial");
        s.Inputs.Should().HaveCount(1);
    }
}
  • Step 3: Run the test to confirm it fails

Run from muscleman-godot4/:

dotnet test --filter "FullyQualifiedName~ScenarioTypeTests" --no-restore || true

Expected: compile failure — The type or namespace 'Scenario' could not be found.

  • Step 4: Implement Scenario and ScenarioContext

Create muscleman-godot4/src/Muscleman.Sim.TestFixtures/Scenario.cs:

using Muscleman.Core.Events;
using Muscleman.Core.Input;

namespace Muscleman.Sim.TestFixtures;

public sealed record ScenarioContext(
    World World,
    int PlayerId,
    IReadOnlyDictionary<string, int> Named);

public sealed record Scenario(
    string Name,
    Func<World, ScenarioContext> Seed,
    IReadOnlyList<Input> Inputs,
    Func<ScenarioContext, IReadOnlyList<SimEvent>> ExpectedEvents);
  • Step 5: Run the test to confirm it passes
dotnet test --filter "FullyQualifiedName~ScenarioTypeTests"

Expected: 1 passed.

  • Step 6: Commit
git add muscleman-godot4/src/Muscleman.Sim.TestFixtures/Scenario.cs \
        muscleman-godot4/src/Muscleman.Sim.Tests/Muscleman.Sim.Tests.csproj \
        muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs
git commit -m "test-fixtures: add Scenario + ScenarioContext records"

Task 3: Implement ScenarioRunner (TDD)

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim.TestFixtures/ScenarioRunner.cs
  • Modify: muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs (add runner test)

  • Step 1: Write the failing test

Append to muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs:

[Fact]
public void ScenarioRunner_drives_inputs_and_flattens_event_batches()
{
    // Player at (0,0), wait once — produces no events.
    var s = new Scenario(
        Name: "wait-once",
        Seed: w =>
        {
            var pid = Muscleman.Sim.Recipes.RecipeFactory.Spawn(
                w, Muscleman.Core.Enums.EntityRecipe.Player, new GridPos(0, 0));
            return new ScenarioContext(w, pid, new Dictionary<string, int>());
        },
        Inputs: new List<Input> { new Input.Wait() },
        ExpectedEvents: _ => new List<SimEvent>());

    var (events, _) = ScenarioRunner.Run(s);

    events.Should().BeEmpty();
}
  • Step 2: Run to confirm it fails
dotnet test --filter "FullyQualifiedName~ScenarioRunner_drives_inputs_and_flattens_event_batches" --no-restore || true

Expected: compile failure — ScenarioRunner not found.

  • Step 3: Implement ScenarioRunner

Create muscleman-godot4/src/Muscleman.Sim.TestFixtures/ScenarioRunner.cs:

using Muscleman.Core.Events;

namespace Muscleman.Sim.TestFixtures;

public static class ScenarioRunner
{
    public static (IReadOnlyList<SimEvent> Events, ScenarioContext Context) Run(Scenario scenario)
    {
        var world = new World();
        var ctx = scenario.Seed(world);

        var all = new List<SimEvent>();
        foreach (var input in scenario.Inputs)
        {
            var batch = StepRunner.Step(ctx.World, ctx.PlayerId, input);
            all.AddRange(batch.Events);
        }

        return (all, ctx);
    }
}
  • Step 4: Run to confirm it passes
dotnet test --filter "FullyQualifiedName~ScenarioRunner_drives_inputs_and_flattens_event_batches"

Expected: 1 passed.

  • Step 5: Commit
git add muscleman-godot4/src/Muscleman.Sim.TestFixtures/ScenarioRunner.cs \
        muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs
git commit -m "test-fixtures: ScenarioRunner — drive inputs, flatten event batches"

Task 4: Author the three starter scenarios (TDD)

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim.TestFixtures/Scenarios.cs
  • Modify: muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs (add catalog test)

  • Step 1: Write the failing test for the catalog

Append to muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs:

[Fact]
public void Scenarios_All_exposes_the_three_starters_by_name()
{
    Scenarios.All.Select(s => s.Name).Should().BeEquivalentTo(new[]
    {
        "WalkOneStepNorth",
        "PushOneBoxEast",
        "GrabThenDrop",
    });
}
  • Step 2: Run to confirm it fails
dotnet test --filter "FullyQualifiedName~Scenarios_All_exposes_the_three_starters_by_name" --no-restore || true

Expected: compile failure — Scenarios not found.

  • Step 3: Implement the catalog

Create muscleman-godot4/src/Muscleman.Sim.TestFixtures/Scenarios.cs:

using Muscleman.Core.Enums;
using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Muscleman.Core.Input;
using Muscleman.Sim.Recipes;

namespace Muscleman.Sim.TestFixtures;

public static class Scenarios
{
    public static readonly Scenario WalkOneStepNorth = new(
        Name: "WalkOneStepNorth",
        Seed: w =>
        {
            var player = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(5, 5));
            return new ScenarioContext(w, player, new Dictionary<string, int>());
        },
        Inputs: new List<Input> { new Input.Move(GridDir.Up) },
        ExpectedEvents: ctx => new List<SimEvent>
        {
            new Moved(ctx.PlayerId, new GridPos(5, 5), new GridPos(5, 4), MoveCause.Player),
        });

    public static readonly Scenario PushOneBoxEast = new(
        Name: "PushOneBoxEast",
        Seed: w =>
        {
            var player = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(5, 5));
            var box = RecipeFactory.Spawn(w, EntityRecipe.Box, new GridPos(6, 5));
            return new ScenarioContext(w, player, new Dictionary<string, int> { ["box"] = box });
        },
        Inputs: new List<Input> { new Input.Move(GridDir.Right) },
        ExpectedEvents: ctx => new List<SimEvent>
        {
            new Moved(ctx.Named["box"], new GridPos(6, 5), new GridPos(7, 5), MoveCause.Push),
            new Moved(ctx.PlayerId, new GridPos(5, 5), new GridPos(6, 5), MoveCause.Player),
        });

    public static readonly Scenario GrabThenDrop = new(
        Name: "GrabThenDrop",
        Seed: w =>
        {
            // Player default facing is GridDir.Down. Place box one cell south.
            var player = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(5, 5));
            var box = RecipeFactory.Spawn(w, EntityRecipe.Box, new GridPos(5, 6));
            return new ScenarioContext(w, player, new Dictionary<string, int> { ["box"] = box });
        },
        Inputs: new List<Input> { new Input.Grab(), new Input.Drop() },
        ExpectedEvents: ctx => new List<SimEvent>
        {
            new GrabAttached(ctx.PlayerId, ctx.Named["box"]),
            new GrabReleased(ctx.PlayerId, ctx.Named["box"]),
        });

    public static readonly IReadOnlyList<Scenario> All = new List<Scenario>
    {
        WalkOneStepNorth,
        PushOneBoxEast,
        GrabThenDrop,
    };
}
  • Step 4: Run to confirm the catalog test passes
dotnet test --filter "FullyQualifiedName~Scenarios_All_exposes_the_three_starters_by_name"

Expected: 1 passed.

  • Step 5: Commit
git add muscleman-godot4/src/Muscleman.Sim.TestFixtures/Scenarios.cs \
        muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs
git commit -m "test-fixtures: starter Scenarios.All (walk, push, grab+drop)"

Task 5: xUnit ParityTests over Scenarios.All

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/ParityTests.cs
  • Delete: muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs (scaffolding, replaced)

  • Step 1: Write the parity tests

Create muscleman-godot4/src/Muscleman.Sim.Tests/ParityTests.cs:

using Muscleman.Sim.TestFixtures;
using FluentAssertions;
using Xunit;

namespace Muscleman.Sim.Tests;

public class ParityTests
{
    public static IEnumerable<object[]> AllScenarios()
        => Scenarios.All.Select(s => new object[] { s });

    [Theory]
    [MemberData(nameof(AllScenarios))]
    public void Scenario_produces_expected_events(Scenario scenario)
    {
        var (actual, ctx) = ScenarioRunner.Run(scenario);
        var expected = scenario.ExpectedEvents(ctx);

        actual.Should().BeEquivalentTo(expected, opts => opts.WithStrictOrdering(),
            $"scenario '{scenario.Name}' must produce the expected event sequence");
    }
}
  • Step 2: Delete the scaffolding test file
rm muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs
  • Step 3: Run the parity tests
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~ParityTests"

Expected: 3 tests passed, 0 failed. If any scenario’s ExpectedEvents is wrong, fix the expectation (not the assertion), then re-run.

  • Step 4: Run the full xUnit suite to confirm no regressions
dotnet test muscleman-godot4/Muscleman.sln

Expected: previous test count + 3 (the new parity tests). All green.

  • Step 5: Commit
git add muscleman-godot4/src/Muscleman.Sim.Tests/ParityTests.cs
git rm muscleman-godot4/src/Muscleman.Sim.Tests/ScenarioTypeTests.cs
git commit -m "tests: xUnit ParityTests theory over Scenarios.All"

Task 6: Create the Godot project at engine/

Files:

  • Create: muscleman-godot4/engine/project.godot
  • Create: muscleman-godot4/engine/.gitignore
  • Create: muscleman-godot4/engine/icon.svg (placeholder, Godot expects one)

  • Step 1: Create the project manifest

Create muscleman-godot4/engine/project.godot:

; Engine-side test harness for the Muscleman sim.
; This project exists solely to host GdUnit4 parity tests under godot --headless.
; No scenes, no presenter; the sim runs inside Godot's mono runtime.

config_version=5

[application]

config/name="MusclemanEngineTests"
config/features=PackedStringArray("4.6", "C#", "Forward Plus")
config/icon="res://icon.svg"

[dotnet]

project/assembly_name="Muscleman.Engine.Tests"
  • Step 2: Create a minimal icon.svg placeholder

Create muscleman-godot4/engine/icon.svg:

<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="128" height="128" fill="#478cbf"/></svg>
  • Step 3: Create the engine .gitignore

Create muscleman-godot4/engine/.gitignore:

# Godot transient state — do not commit
.godot/
.import/

# Mono / .NET build outputs inside the Godot project
.mono/
bin/
obj/

# Godot 4.x per-resource UID sidecar files (regenerated on import)
*.uid
  • Step 4: Prime the project — first headless import
/home/henri/godot/godot --headless --path muscleman-godot4/engine --import --quit-after 2 || true

Expected: Godot prints “Scanning…”, imports the icon, then exits. A .godot/ directory appears under engine/. Exit code may be non-zero on first run — that’s fine for priming. || true swallows that.

Verify the cache directory exists:

ls muscleman-godot4/engine/.godot/ 2>/dev/null | head

Expected: non-empty listing.

  • Step 5: Confirm .gitignore keeps the cache out of staging
git status --porcelain muscleman-godot4/engine/ | grep -E "^(M|A|\?\?)" | grep ".godot/" || echo "ok — .godot/ ignored"

Expected: ok — .godot/ ignored.

  • Step 6: Commit
git add muscleman-godot4/engine/project.godot \
        muscleman-godot4/engine/icon.svg \
        muscleman-godot4/engine/.gitignore
git commit -m "engine: scaffold Godot 4.6 mono project for sim parity tests"

Task 7: Wire C# + GdUnit4Net into the engine project

Files:

  • Create: muscleman-godot4/engine/Muscleman.Engine.Tests.csproj
  • Modify: muscleman-godot4/Muscleman.sln (add the new project)

  • Step 1: Determine the GdUnit4Net version compatible with Godot 4.6.2

Run:

dotnet package search gdUnit4.api --take 10

From the printed list, pick the highest stable version that supports Godot 4.6.x (check release notes on https://github.com/MikeSchulze/gdUnit4Net/releases for the chosen version). Note that version — referred to below as <GDUNIT_VER>. If unsure, start with the latest stable; if integration fails in Task 9, downgrade one minor version and retry.

  • Step 2: Hand-write the engine test csproj

Create muscleman-godot4/engine/Muscleman.Engine.Tests.csproj, substituting your chosen <GDUNIT_VER>:

<Project Sdk="Godot.NET.Sdk/4.6.0">

  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <EnableDynamicLoading>true</EnableDynamicLoading>
    <Nullable>enable</Nullable>
    <RootNamespace>Muscleman.Engine.Tests</RootNamespace>
    <AssemblyName>Muscleman.Engine.Tests</AssemblyName>
  </PropertyGroup>

  <ItemGroup>
    <!-- Pin GdUnit4Net to the version chosen in Step 1. -->
    <PackageReference Include="gdUnit4.api" Version="<GDUNIT_VER>" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\src\Muscleman.Core\Muscleman.Core.csproj" />
    <ProjectReference Include="..\src\Muscleman.Sim\Muscleman.Sim.csproj" />
    <ProjectReference Include="..\src\Muscleman.Sim.TestFixtures\Muscleman.Sim.TestFixtures.csproj" />
  </ItemGroup>

</Project>
  • Step 3: Add it to the solution
dotnet sln muscleman-godot4/Muscleman.sln add muscleman-godot4/engine/Muscleman.Engine.Tests.csproj

Expected: Project ... added to the solution.

  • Step 4: Restore and build the engine project
dotnet build muscleman-godot4/engine/Muscleman.Engine.Tests.csproj

Expected: build succeeds with 0 errors. If you see error NU1101: Unable to find package 'gdUnit4.api', the version you picked in Step 1 isn’t actually published — re-run the search and pick another.

If you see Godot.NET.Sdk/4.6.0 resolution errors, try Godot.NET.Sdk/4.6.2 (the exact version matches your installed Godot). The SDK is delivered as a NuGet package; whichever installed-Godot-aligned version restores cleanly is correct.

  • Step 5: Commit
git add muscleman-godot4/engine/Muscleman.Engine.Tests.csproj muscleman-godot4/Muscleman.sln
git commit -m "engine: wire C# csproj with GdUnit4Net + sim project refs"

Task 8: Install the GdUnit4 GDScript addon for the headless runner

Files:

  • Create: muscleman-godot4/engine/addons/gdUnit4/ (tree of vendored addon files)

The GdUnit4Net NuGet package provides the C# test API, but the headless test runner is a GDScript tool that lives under res://addons/gdUnit4/. It must be committed to the repo so CI / fresh clones can run tests.

  • Step 1: Identify the matching GdUnit4 addon release

The GdUnit4 GDScript addon version must match (or be compatible with) the gdUnit4.api NuGet version chosen in Task 7. Check https://github.com/MikeSchulze/gdUnit4/releases for a release with the same major version as your chosen <GDUNIT_VER>. Note the release tag (e.g. v4.4.0).

  • Step 2: Download and extract the addon
cd /tmp
GDUNIT_TAG="<release-tag-from-step-1>"
curl -L "https://github.com/MikeSchulze/gdUnit4/releases/download/${GDUNIT_TAG}/gdUnit4-${GDUNIT_TAG}.zip" -o gdUnit4.zip
unzip -q gdUnit4.zip -d gdUnit4-extracted
ls gdUnit4-extracted/addons/

Expected: gdUnit4 directory listed.

  • Step 3: Copy the addon into the engine project
mkdir -p /home/henri/muscleman/muscleman-godot4/engine/addons
cp -r /tmp/gdUnit4-extracted/addons/gdUnit4 /home/henri/muscleman/muscleman-godot4/engine/addons/
ls /home/henri/muscleman/muscleman-godot4/engine/addons/gdUnit4/ | head

Expected: a tree including bin/, src/, plugin.cfg, etc.

  • Step 4: Re-prime Godot to pick up the addon
/home/henri/godot/godot --headless --path muscleman-godot4/engine --import --quit-after 2 || true

Expected: imports succeed; no errors about missing addons.

  • Step 5: Locate the headless runner script and confirm --help works

The runner script path depends on the GdUnit4 version. For 4.x it’s typically at addons/gdUnit4/bin/GdUnitCmdTool.gd. Verify:

ls muscleman-godot4/engine/addons/gdUnit4/bin/*.gd

Expected: at least one .gd file, likely GdUnitCmdTool.gd or runtest.gd. Note the actual filename — referred to below as <RUNNER>.

Probe its CLI:

/home/henri/godot/godot --headless --path muscleman-godot4/engine \
    -s "addons/gdUnit4/bin/<RUNNER>" --help 2>&1 | head -40

Expected: a help screen listing flags like -a (add suite path), -c (continue on failure), etc. Note the exact flag syntax — needed in Task 10.

  • Step 6: Commit the addon
git add muscleman-godot4/engine/addons/gdUnit4
git commit -m "engine: vendor GdUnit4 addon for headless test runner"

This commit is large (hundreds of files). That’s expected — the addon must be in-repo for headless runs.


Task 9: Write engine-side ParityTests (TDD against godot --headless)

Files:

  • Create: muscleman-godot4/engine/tests/ParityTests.cs

  • Step 1: Write all three parity tests up front

Create muscleman-godot4/engine/tests/ParityTests.cs:

using GdUnit4;
using Muscleman.Sim.TestFixtures;
using static GdUnit4.Assertions;

namespace Muscleman.Engine.Tests;

[TestSuite]
public class ParityTests
{
    [TestCase]
    public void WalkOneStepNorth() => RunScenarioByName("WalkOneStepNorth");

    [TestCase]
    public void PushOneBoxEast() => RunScenarioByName("PushOneBoxEast");

    [TestCase]
    public void GrabThenDrop() => RunScenarioByName("GrabThenDrop");

    private static void RunScenarioByName(string name)
    {
        var scenario = Scenarios.All.FirstOrDefault(s => s.Name == name)
            ?? throw new InvalidOperationException(
                $"Scenario '{name}' not found in Scenarios.All — engine test out of sync with fixtures.");

        var (actual, ctx) = ScenarioRunner.Run(scenario);
        var expected = scenario.ExpectedEvents(ctx);

        AssertArray(actual.ToArray()).IsEqual(expected.ToArray());
    }
}

If GdUnit4.Assertions.AssertArray(...).IsEqual(...) doesn’t compile against the chosen GdUnit4Net version, substitute the equivalent assertion from your version’s Assertions static class (likely AssertObject(actual).IsEqual(expected) or AssertThat(actual).IsEqual(expected)).

  • Step 2: Build the engine project
dotnet build muscleman-godot4/engine/Muscleman.Engine.Tests.csproj

Expected: 0 errors. The [TestSuite] and [TestCase] attributes come from gdUnit4.api.

  • Step 3: Run the headless test suite

Using the runner filename and flag set discovered in Task 8 Step 5. The typical 4.x invocation is:

/home/henri/godot/godot --headless --path muscleman-godot4/engine \
    -s "addons/gdUnit4/bin/GdUnitCmdTool.gd" \
    -a tests \
    -c

Adjust to your discovered runner name and flag set. Expected output ends with:

Test run summary: 3 passed, 0 failed

Exit code 0.

  • Step 4: Force a failure to confirm the assertion path actually checks

Temporarily edit Scenarios.cs — change the expected (5, 4) in WalkOneStepNorth to (5, 3). Re-run the headless command. Expected: 1 failed, with a diff showing the actual (5, 4) vs expected (5, 3). Revert the change.

  • Step 5: Re-run to confirm green again

Re-run the headless command from Step 3. Expected: 3 passed, 0 failed.

  • Step 6: Commit
git add muscleman-godot4/engine/tests/ParityTests.cs
git commit -m "engine-tests: GdUnit4 ParityTests over Scenarios.All"

Task 10: Makefile orchestration

Files:

  • Create: muscleman-godot4/Makefile

  • Step 1: Write the Makefile

Create muscleman-godot4/Makefile, substituting the runner filename and flag set discovered in Task 8 Step 5 (and used in Task 9 Step 3):

# Muscleman test orchestration.
# Run `make test` to exercise both lanes.

GODOT ?= /home/henri/godot/godot
ENGINE_DIR := engine
GDUNIT_RUNNER := addons/gdUnit4/bin/GdUnitCmdTool.gd

.PHONY: test test-sim test-engine engine-prime clean

test: test-sim test-engine

test-sim:
	dotnet test Muscleman.sln

# Run engine parity tests under godot --headless.
test-engine: engine-prime
	$(GODOT) --headless --path $(ENGINE_DIR) -s $(GDUNIT_RUNNER) -a tests -c

# One-off priming: imports Godot resources on a fresh checkout so the
# subsequent test run doesn't hit "import in progress" warnings.
# Safe to re-run; idempotent once .godot/ exists.
engine-prime:
	@if [ ! -d $(ENGINE_DIR)/.godot ]; then \
	  echo "Priming Godot import cache..."; \
	  $(GODOT) --headless --path $(ENGINE_DIR) --import --quit-after 2 || true; \
	fi

clean:
	rm -rf $(ENGINE_DIR)/.godot $(ENGINE_DIR)/.import $(ENGINE_DIR)/.mono
	find $(ENGINE_DIR) src -type d \( -name bin -o -name obj \) -exec rm -rf {} +

If your GDUNIT_RUNNER filename or flag set differs (per Task 8 Step 5), edit the constants and the test-engine recipe accordingly.

  • Step 2: Verify each target runs
make -C muscleman-godot4 test-sim

Expected: full xUnit suite green.

make -C muscleman-godot4 test-engine

Expected: 3 parity tests green.

make -C muscleman-godot4 test

Expected: both lanes run sequentially and both go green.

  • Step 3: Commit
git add muscleman-godot4/Makefile
git commit -m "build: Makefile — test (both lanes), test-sim, test-engine, clean"

Task 11: Repo .gitignore hygiene check

Files:

  • Modify: /home/henri/muscleman/.gitignore (root, if Godot transients leak above engine/)

  • Step 1: Inspect what make test left behind

git status --porcelain | head -30

Expected: clean (no untracked files). If anything Godot-related appears outside engine/.gitignore’s coverage, add it to the engine .gitignore (if scoped) or the root .gitignore (if global).

  • Step 2: If any leak was found, append to the appropriate .gitignore

For each leaked path, identify whether it’s engine-local (scope it to muscleman-godot4/engine/.gitignore) or repo-wide (scope it to the root .gitignore). Append precise patterns, not wildcards that risk over-ignoring.

  • Step 3: Verify git status is now clean
git status --porcelain

Expected: empty output.

  • Step 4: Commit (only if Step 2 produced changes)
git add muscleman-godot4/engine/.gitignore .gitignore
git commit -m "engine: ignore additional Godot transients leaking from headless runs"

If nothing changed, skip the commit.


Task 12: Final verification against Definition of Done

This task runs no new code — it confirms the spec’s DoD (§8) is met.

  • DoD 1: make test from muscleman-godot4/ runs both lanes; both green
make -C muscleman-godot4 test

Expected: both lanes green.

  • DoD 2: xUnit lane has ≥ 3 new ParityTests, total suite size grows by exactly 3

Compare counts:

git stash
dotnet test muscleman-godot4/Muscleman.sln 2>&1 | grep -E "Passed:|Failed:"  # baseline
git stash pop
dotnet test muscleman-godot4/Muscleman.sln 2>&1 | grep -E "Passed:|Failed:"  # after

Expected: after-count = baseline-count + 3.

  • DoD 3: GdUnit4 lane has ≥ 3 parity tests; each maps 1:1 to Scenarios.All
make -C muscleman-godot4 test-engine 2>&1 | tail -10

Expected output shows 3 tests passed, names match WalkOneStepNorth, PushOneBoxEast, GrabThenDrop.

  • DoD 4: GODOT env var override works
GODOT=/nonexistent/godot make -C muscleman-godot4 test-engine 2>&1 | head -5

Expected: command fails loudly with a “no such file” / “command not found” message. (Confirms the variable is actually consulted.) Re-run with the real path to restore green.

  • DoD 5: No new flakes in existing sim suite
for i in 1 2 3; do
  dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName!~ParityTests" 2>&1 | grep -E "Passed:|Failed:"
done

Expected: identical pass/fail counts on all three runs.

  • DoD 6: git status clean after a fresh make test
make -C muscleman-godot4 clean
make -C muscleman-godot4 test
git status --porcelain

Expected: empty output.

  • Step 7: Done — no commit; this task just verifies

Self-review log

The plan against the spec:

  • §2 architecture (two-lane sharing TestFixtures) → Tasks 1–5 (lane 1), Tasks 6–9 (lane 2). ✓
  • §3 directory layout → Task 1 (TestFixtures), Task 6 (engine), Task 10 (Makefile). ✓
  • §4.1 Scenario/ScenarioRunner/Scenarios.All → Tasks 2–4. ✓
  • §4.2 xUnit ParityTests → Task 5. ✓
  • §4.3 GdUnit4 ParityTests → Task 9. ✓
  • §4.4 invocation (dotnet test, make test-engine, make test, GODOT override) → Task 10 + DoD 4. ✓
  • §6 test discipline → embodied in scenario authoring (Task 4: expected events hand-authored, source-controlled, in All).
  • §7 gotchas:
    • First-run import → Task 6 Step 4 + Task 10 engine-prime target. ✓
    • GdUnit4Net version pin → Task 7 Step 1, propagated to Step 2. ✓
    • C# csproj generation → Task 7 (hand-written, bypassing Godot’s editor-based generation since this is headless-only). ✓
    • .godot/ etc. ignored, addons/gdUnit4/ committed → Task 6 Step 3 + Task 8 Step 6. ✓
  • §8 DoD items 1–6 → Task 12 explicit checks. ✓
  • §9 deferred questions:
    • Q1 (Seed shape) → “Locking in spec open questions” preamble + Task 2 Step 4 (ScenarioContext). ✓
    • Q2 ([TestCase] parameterisation) → preamble + Task 9 (one method per scenario). ✓
    • Q3 (headless flag set) → Task 8 Step 5 (probe) + Task 9 Step 3 (use) + Task 10 (Makefile). ✓

No placeholders found in code blocks; only <GDUNIT_VER> and <RUNNER> are deliberately marked for the engineer to fill in from version research in Task 7 Step 1 and Task 8 Step 5 (both have explicit dotnet package search / ls commands so the engineer doesn’t have to guess).

Type-consistency check: Scenario.ExpectedEvents is Func<ScenarioContext, IReadOnlyList<SimEvent>> everywhere it’s referenced (Task 2 definition, Task 4 usage, Task 5 invocation, Task 9 invocation). ScenarioRunner.Run returns (IReadOnlyList<SimEvent>, ScenarioContext) consistently. ✓