Muscleman — Engine-side test harness (sim parity under Godot)
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/godotand 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.monois 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.
ExpectedEventsis 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.All — public static IReadOnlyList<Scenario> exposing the starter set:
WalkOneStepNorth— empty grid, player at(5,5), inputMove(N). Expects oneWalkedevent.PushOneBoxEast— player at(1,1)facing E, box at(2,1), inputMove(E). Expects oneMoved(box, Push)followed by oneWalked.GrabThenDrop— player adjacent to a box, inputsGrabthenDrop. ExpectsGrabbedfollowed byDropped.
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(frommuscleman-godot4/) — runs the entire xUnit suite includingParityTests. 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.GODOTdefaults to/home/henri/godot/godotand 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 whatSeedestablishes explicitly. ExpectedEventsis 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
ExpectedEventsis 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 testprovides). Rare.
7. Gotchas to handle in the implementation plan
- Godot’s first
--headlessinvocation 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 —--importplus 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.csprojis 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 (addProjectReferencetoMuscleman.Sim.TestFixtures, addPackageReferencetoGdUnit4Net)..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 (StepRunnervsWorld.Step) is resolved in a separate change,ScenarioRunnermust follow whichever API survives.
8. Definition of done
make testfrommuscleman-godot4/runs both lanes; both green.- xUnit lane has ≥ 3 new
ParityTests(one per starter scenario), bringing the suite to ≥ 51 tests. - GdUnit4 lane has ≥ 3 parity tests; each maps 1:1 to a scenario in
Scenarios.All. - Engine binary path is overridable via
GODOTenv var; defaults to/home/henri/godot/godot. - No new flakes in the existing sim suite.
git statusafter a freshmake testshows no transient Godot files leaking into version control.
9. Open questions explicitly deferred to the implementation plan
- Exact
GdUnit4Netpackage version that pairs cleanly with Godot 4.6.2-stable mono. - Whether GdUnit4Net’s
[TestCase]data-driven attribute supportsScenarioparameters directly or whether each scenario gets its own method. - The precise
GdUnitRunner.gdflag 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.