Muscleman — Engine-side test harness (sim parity under Godot)

Status: Design approved, ready for implementation planning. Author: henri (with Claude) Date: 2026-05-13

1. Goal and non-goals

Goal

Stand up an engine-side test lane that proves the existing pure-C# simulation produces the same events when hosted inside Godot 4.6.2 mono as it does under plain dotnet test. The lane shares scenario data with the xUnit tests so the two suites cannot drift; a divergence between them is the parity bug we want to surface.

Why now

  • Phase 2 (grab/drop, swing slide) has landed locally. Sim behavior is now non-trivial enough that “it runs in Godot” deserves verification rather than assumption.
  • No presenter exists yet, so we can build the test lane unburdened by presenter concerns. When the presenter lands, parity tests already gate sim regressions caused by hosting changes.
  • The user has Godot 4.6.2-stable mono installed at /home/henri/godot/godot and wants engine signal in the build loop now.

Non-goals

  • Presenter correctness. There is no presenter and this work does not build one.
  • Visual / scene rendering. No scenes, nodes, or rendering pipeline are exercised.
  • Performance / determinism across platforms or Godot versions. Only the locally installed 4.6.2-stable.mono is tested.
  • Replacing the xUnit suite. The xUnit lane stays primary; the engine lane is an additional cross-check.
  • Auto-blessing expected events from a run. ExpectedEvents is hand-authored — otherwise the test asserts the sim against itself.

2. Architecture

Two test lanes consume one shared scenario library. The scenario library has no Godot dependency.

┌──────────────────────────────────────────────────────────────────┐
│  Muscleman.Sim.TestFixtures (pure C# net9.0)                     │
│    - Scenario record (Seed, Inputs, ExpectedEvents)              │
│    - ScenarioRunner.Run(Scenario) -> SimEvent[]                  │
│    - Scenarios.All (WalkOneStepNorth, PushOneBoxEast, Grab+Drop) │
└─────────────────┬──────────────────────────┬─────────────────────┘
                  │                          │
                  ▼                          ▼
   ┌──────────────────────────┐   ┌──────────────────────────────┐
   │ src/Muscleman.Sim.Tests  │   │ engine/ (Godot 4.6.2 mono)   │
   │   xUnit ParityTests      │   │   GdUnit4 ParityTests        │
   │   runs under dotnet test │   │   runs under godot --headless│
   └──────────────────────────┘   └──────────────────────────────┘

Both lanes invoke the same ScenarioRunner.Run and assert on the same ExpectedEvents. The only difference is the hosting runtime.

3. Directory layout

muscleman-godot4/
├── Muscleman.sln                              (adds 2 new projects)
├── Makefile                                   NEW — `test`, `test-engine`, combined
├── src/
│   ├── Muscleman.Core/                        (unchanged)
│   ├── Muscleman.Sim/                         (unchanged)
│   ├── Muscleman.Sim.Tests/                   (existing xUnit — gains ParityTests.cs)
│   └── Muscleman.Sim.TestFixtures/            NEW — scenario library
│       ├── Muscleman.Sim.TestFixtures.csproj  (net9.0 classlib; refs Sim + Core)
│       ├── Scenario.cs
│       ├── ScenarioRunner.cs
│       └── Scenarios.cs
└── engine/                                    NEW — Godot project root
    ├── project.godot                          (4.6 mono, .NET 9, headless-friendly)
    ├── Muscleman.Engine.Tests.csproj          (Godot-generated; refs Sim + TestFixtures + GdUnit4Net)
    ├── addons/gdUnit4/                        (committed addon — required for headless runner)
    ├── tests/
    │   └── ParityTests.cs                     (GdUnit4 test suite)
    └── .gitignore                             (.godot/, .mono/, *.uid)

muscleman-godot4/ root is not the Godot project. Godot’s expected res:// layout lives one level down at engine/, isolating Godot’s import side effects from the sim source tree.

4. Components

4.1 Muscleman.Sim.TestFixtures (new shared library)

Scenario — immutable record:

public sealed record Scenario(
    string Name,
    Func<World, int> Seed,            // populates world, returns the player entity id
    IReadOnlyList<Input> Inputs,
    IReadOnlyList<SimEvent> ExpectedEvents);

The Seed returns the player id so each scenario owns its player creation rather than relying on a convention.

ScenarioRunner.Run(Scenario s) — creates a fresh World, calls s.Seed(world) to obtain playerId, drives one StepRunner.Step(world, playerId, input) per input, flattens every emitted EventBatch into a single IReadOnlyList<SimEvent>, returns it.

Scenarios.Allpublic static IReadOnlyList<Scenario> exposing the starter set:

  • WalkOneStepNorth — empty grid, player at (5,5), input Move(N). Expects one Walked event.
  • PushOneBoxEast — player at (1,1) facing E, box at (2,1), input Move(E). Expects one Moved(box, Push) followed by one Walked.
  • GrabThenDrop — player adjacent to a box, inputs Grab then Drop. Expects Grabbed followed by Dropped.

These three are starter coverage. New scenarios are added to Scenarios.All and both lanes pick them up automatically.

4.2 Muscleman.Sim.Tests/ParityTests.cs

Single xUnit [Theory] over Scenarios.All. For each scenario, calls ScenarioRunner.Run and asserts via FluentAssertions:

actual.Should().BeEquivalentTo(scenario.ExpectedEvents, opts => opts.WithStrictOrdering());

Strict ordering matters — event order is part of the contract.

4.3 engine/tests/ParityTests.cs

GdUnit4 [TestSuite] class. One test method per scenario (or a data-driven test if GdUnit4Net’s [TestCase] parameterisation supports Scenario cleanly — confirmed during implementation). Each test calls the same ScenarioRunner.Run (now executing under Godot’s mono runtime) and asserts equivalence via GdUnit4’s assertion API.

4.4 Invocation

  • dotnet test (from muscleman-godot4/) — runs the entire xUnit suite including ParityTests. Unchanged ergonomics.
  • make test-engine — runs $(GODOT) --headless --path engine -s addons/gdUnit4/bin/GdUnitRunner.gd --testsuites tests/ (exact runner script and flag set pinned during implementation against the chosen GdUnit4Net version). Exit code 0 on green, non-zero on failure.
  • make test — runs both lanes sequentially.
  • GODOT defaults to /home/henri/godot/godot and is overridable via env var.

5. Data flow

Scenario data ──► ScenarioRunner ──► SimEvent[] ──► assertion
       │
       ├──► invoked under xUnit  (pure dotnet 9 runtime)
       └──► invoked under GdUnit4 (Godot 4.6.2-stable mono / .NET 9)

Both lanes traverse identical code through ScenarioRunner. Difference is purely the hosting runtime. A parity failure means either (a) the sim has runtime-dependent behavior — the bug we’re hunting — or (b) the scenario itself diverged across lanes, which the shared library makes impossible by construction.

6. Test discipline

  • Scenarios must be deterministic: no Random, no time-of-day, no entity-creation-order dependence beyond what Seed establishes explicitly.
  • ExpectedEvents is hand-authored and source-controlled. We do not bless from runtime output.
  • A scenario is only “real” once it appears in Scenarios.All; one-off scenarios in only one lane defeat the purpose.
  • The signal table:
    • xUnit green + engine green → steady state.
    • xUnit green + engine red → parity bug (runtime-dependent sim behavior under Godot). Investigate the sim, not the scenario.
    • xUnit red + engine red, identical failure → the sim regressed or ExpectedEvents is stale. Fix whichever is wrong; do not bless from current output without reasoning about it.
    • xUnit red + engine green → broken scenario authoring (e.g., relies on something only dotnet test provides). Rare.

7. Gotchas to handle in the implementation plan

  • Godot’s first --headless invocation on a fresh project imports assets and may exit non-zero. The Makefile target performs a one-time import-and-quit priming step before running tests (exact flag combination — --import plus an immediate quit — is settled during implementation against 4.6.2’s behavior).
  • GdUnit4Net version must match Godot 4.6.2 — wrong versions produce obscure runtime errors. Pinned explicitly in the csproj.
  • engine/Muscleman.Engine.Tests.csproj is auto-generated by Godot the first time C# is added to the project. The plan covers both manual generation (Godot editor’s “Create C# solution” step, run once) and post-generation tweaks (add ProjectReference to Muscleman.Sim.TestFixtures, add PackageReference to GdUnit4Net).
  • .godot/mono/ and other transient Godot directories must be gitignored. addons/gdUnit4/ (the committed addon) must NOT be — it is required at runtime under --headless.
  • The sim’s StepRunner.Step(World, playerId, Input) is the current public step entry point. If the spec-violation issue (StepRunner vs World.Step) is resolved in a separate change, ScenarioRunner must follow whichever API survives.

8. Definition of done

  1. make test from muscleman-godot4/ runs both lanes; both green.
  2. xUnit lane has ≥ 3 new ParityTests (one per starter scenario), bringing the suite to ≥ 51 tests.
  3. GdUnit4 lane has ≥ 3 parity tests; each maps 1:1 to a scenario in Scenarios.All.
  4. Engine binary path is overridable via GODOT env var; defaults to /home/henri/godot/godot.
  5. No new flakes in the existing sim suite.
  6. git status after a fresh make test shows no transient Godot files leaking into version control.

9. Open questions explicitly deferred to the implementation plan

  • Exact GdUnit4Net package version that pairs cleanly with Godot 4.6.2-stable mono.
  • Whether GdUnit4Net’s [TestCase] data-driven attribute supports Scenario parameters directly or whether each scenario gets its own method.
  • The precise GdUnitRunner.gd flag set for selecting test suites under --headless (the GdUnit4 CLI surface has changed across versions).

These are research items, not design decisions — settling them does not change the architecture above.