Sim phase 4 — Environmental phase and rule-set completion

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: Close the puzzle ruleset in the engine-agnostic sim by adding the environmental tick phase (sinking, plates, signals, doors, secret triggers, level transitions) and the two adjacent player-phase consequences (fruit eating, key-into-door), driven test-first scenario-by-scenario.

Architecture: One static system class per rule (SinkingSystem, PressurePlateSystem, SecretTriggerSystem, SignalSystem, DoorSystem, LevelTransitionSystem, CleanupSystem). Wired into StepRunner.Step after PlayerActionSystem. Player-phase additions extend PlayerActionSystem / PushSystem in place. New components are flat structs; new tags are zero-size ITag markers. Entity destruction is deferred via a PendingDestroy tag drained by CleanupSystem at end of tick. Two new World properties — Counters and ActiveSignalGroups — hold tick-scoped derived state.

Tech Stack: C# / .NET 9 · Friflo Engine ECS (existing) · xUnit + FluentAssertions (existing) · GdUnit4 (existing engine lane).


Spec reference

This plan implements docs/superpowers/specs/2026-05-13-sim-environmental-and-rule-set-completion-design.md. Read §3 (components & resources), §4 (systems & tick order), §5 (player-phase extensions), §7 (test strategy), and §8 (risks) before starting.

Spec-to-task coverage

Spec section Task(s)
§3.1 components 1, 6
§3.2 tags 1, 2, 8
§3.3 enums 1
§3.4 World resources 1, 6
§4.1 SinkingSystem 3
§4.2 PressurePlateSystem 4
§4.3 SecretTriggerSystem 5
§4.4 SignalSystem 6
§4.5 DoorSystem 7
§4.6 LevelTransitionSystem 8
§4.7 CleanupSystem 2
§5.1 fruit eaten when held 11
§5.2 fruit eaten when pushed 12
§5.3 key consumed by matching door 13
§7 scenarios 14

File structure

muscleman-godot4/
├── src/
│   ├── Muscleman.Core/
│   │   └── Enums/
│   │       └── SignalBehavior.cs                       NEW
│   ├── Muscleman.Sim/
│   │   ├── World.cs                                    MODIFIED — Counters + ActiveSignalGroups
│   │   ├── StepRunner.cs                               MODIFIED — wire env + cleanup phases
│   │   ├── Components/
│   │   │   ├── Tags.cs                                 MODIFIED — Pressed, OnLevelTransition, PendingDestroy
│   │   │   ├── PressurePlate.cs                        NEW
│   │   │   ├── SignalTarget.cs                         NEW
│   │   │   ├── SecretTrigger.cs                        NEW
│   │   │   ├── Named.cs                                NEW
│   │   │   ├── LevelTransition.cs                      NEW
│   │   │   ├── Lock.cs                                 NEW (KeyOf + LockedBy)
│   │   │   └── Edible.cs                               NEW
│   │   ├── Recipes/
│   │   │   └── RecipeFactory.cs                        MODIFIED — fruit/key/door/plate/stairs recipes
│   │   ├── Systems/
│   │   │   ├── PlayerActionSystem.cs                   MODIFIED — fruit consumption
│   │   │   ├── PushSystem.cs                           MODIFIED — key-into-door + fruit-on-push-fail
│   │   │   ├── SinkingSystem.cs                        NEW
│   │   │   ├── PressurePlateSystem.cs                  NEW
│   │   │   ├── SecretTriggerSystem.cs                  NEW
│   │   │   ├── SignalSystem.cs                         NEW
│   │   │   ├── DoorSystem.cs                           NEW
│   │   │   ├── LevelTransitionSystem.cs                NEW
│   │   │   └── CleanupSystem.cs                        NEW
│   ├── Muscleman.Sim.Tests/
│   │   ├── TestWorld.cs                                MODIFIED — fluent helpers for new recipes
│   │   ├── SinkingSystemTests.cs                       NEW
│   │   ├── PressurePlateSystemTests.cs                 NEW
│   │   ├── SecretTriggerSystemTests.cs                 NEW
│   │   ├── SignalSystemTests.cs                        NEW
│   │   ├── DoorSystemTests.cs                          NEW
│   │   ├── LevelTransitionSystemTests.cs               NEW
│   │   ├── CleanupSystemTests.cs                       NEW
│   │   ├── FruitTests.cs                               NEW
│   │   └── KeyIntoDoorTests.cs                         NEW
│   └── Muscleman.Sim.TestFixtures/
│       └── Scenarios.cs                                MODIFIED — append env-phase scenarios

Rationale: one file per component/struct so additions are searchable; one file per system mirroring the existing PushSystem / SwingSystem layout. Tests follow the existing <System>Tests.cs pattern.

Conventions used throughout

  • All tests use xUnit + FluentAssertions. Pattern is established in Muscleman.Sim.Tests/WalkTests.cs.
  • Test setup goes through TestWorld where a fluent builder helps; otherwise build entities directly via World.SpawnEmpty, World.AddTag<T>, World.Set(id, component).
  • New systems are public static class with a Tick(World w, EventBatch events) method (some take int playerId too — noted per system).
  • After every implementation step, run dotnet test muscleman-godot4/Muscleman.sln from the repo root and expect green.
  • Commits use the existing prefix style: sim:, sim-tests:, core:. Keep messages tight (1-2 lines + Co-Authored-By).
  • Determinism: every system that iterates an entity query uses .OrderBy(id => id) before processing. The existing PushSystem is already deterministic without sort because it walks a line, but new systems iterating multiple unrelated entities must sort.

Task 1: Add SignalBehavior enum, new components, and new tags

Files:

  • Create: muscleman-godot4/src/Muscleman.Core/Enums/SignalBehavior.cs
  • Create: muscleman-godot4/src/Muscleman.Sim/Components/PressurePlate.cs
  • Create: muscleman-godot4/src/Muscleman.Sim/Components/SignalTarget.cs
  • Create: muscleman-godot4/src/Muscleman.Sim/Components/SecretTrigger.cs
  • Create: muscleman-godot4/src/Muscleman.Sim/Components/Named.cs
  • Create: muscleman-godot4/src/Muscleman.Sim/Components/LevelTransition.cs
  • Create: muscleman-godot4/src/Muscleman.Sim/Components/Lock.cs
  • Create: muscleman-godot4/src/Muscleman.Sim/Components/Edible.cs
  • Modify: muscleman-godot4/src/Muscleman.Sim/Components/Tags.cs

  • Step 1: Create the SignalBehavior enum

Write muscleman-godot4/src/Muscleman.Core/Enums/SignalBehavior.cs:

namespace Muscleman.Core.Enums;

public enum SignalBehavior
{
    OpenWhilePressed,
    OpenOnPressed,
    OpenWhileReleased,
}
  • Step 2: Create the component structs

Each file has the same using headers. Write muscleman-godot4/src/Muscleman.Sim/Components/PressurePlate.cs:

using Friflo.Engine.ECS;

namespace Muscleman.Sim.Components;

public struct PressurePlate : IComponent
{
    public int Group;
    public bool RequiresHeavy;
    public PressurePlate(int group, bool requiresHeavy)
    {
        Group = group;
        RequiresHeavy = requiresHeavy;
    }
}

Write muscleman-godot4/src/Muscleman.Sim/Components/SignalTarget.cs:

using Friflo.Engine.ECS;
using Muscleman.Core.Enums;

namespace Muscleman.Sim.Components;

public struct SignalTarget : IComponent
{
    public int Group;
    public SignalBehavior Behavior;
    public SignalTarget(int group, SignalBehavior behavior)
    {
        Group = group;
        Behavior = behavior;
    }
}

Write muscleman-godot4/src/Muscleman.Sim/Components/SecretTrigger.cs:

using Friflo.Engine.ECS;

namespace Muscleman.Sim.Components;

public struct SecretTrigger : IComponent
{
    public string EntityName;
    public int Group;
    public SecretTrigger(string entityName, int group)
    {
        EntityName = entityName;
        Group = group;
    }
}

Write muscleman-godot4/src/Muscleman.Sim/Components/Named.cs:

using Friflo.Engine.ECS;

namespace Muscleman.Sim.Components;

public struct Named : IComponent
{
    public string Name;
    public Named(string name) { Name = name; }
}

Write muscleman-godot4/src/Muscleman.Sim/Components/LevelTransition.cs:

using Friflo.Engine.ECS;

namespace Muscleman.Sim.Components;

public struct LevelTransition : IComponent
{
    public string TargetLevelId;
    public string TargetSpawnId;
    public LevelTransition(string targetLevelId, string targetSpawnId)
    {
        TargetLevelId = targetLevelId;
        TargetSpawnId = targetSpawnId;
    }
}

Write muscleman-godot4/src/Muscleman.Sim/Components/Lock.cs:

using Friflo.Engine.ECS;
using Muscleman.Core.Enums;

namespace Muscleman.Sim.Components;

public struct KeyOf : IComponent
{
    public KeyColor Color;
    public KeyOf(KeyColor color) { Color = color; }
}

public struct LockedBy : IComponent
{
    public KeyColor Color;
    public LockedBy(KeyColor color) { Color = color; }
}

Write muscleman-godot4/src/Muscleman.Sim/Components/Edible.cs:

using Friflo.Engine.ECS;
using Muscleman.Core.Enums;

namespace Muscleman.Sim.Components;

public struct Edible : IComponent
{
    public GlobalCounter Counter;
    public Edible(GlobalCounter counter) { Counter = counter; }
}
  • Step 3: Add the new tags

Append to muscleman-godot4/src/Muscleman.Sim/Components/Tags.cs (after the existing Door/DoorOpen block, keep one trailing newline at end of file):

// Pressed state (set by PressurePlateSystem / SecretTriggerSystem each tick)
public struct Pressed        : ITag { }

// Level-transition debounce (set on the player while standing on stairs)
public struct OnLevelTransition : ITag { }

// End-of-tick destruction queue (drained by CleanupSystem)
public struct PendingDestroy : ITag { }
  • Step 4: Verify it builds

Run from repo root:

dotnet build muscleman-godot4/Muscleman.sln

Expected: build succeeds with 0 errors. No tests are added in this task; types are declared only.

  • Step 5: Commit
git add muscleman-godot4/src/Muscleman.Core/Enums/SignalBehavior.cs \
        muscleman-godot4/src/Muscleman.Sim/Components/
git commit -m "$(cat <<'EOF'
sim: scaffold env-phase components, tags, and SignalBehavior enum

Adds PressurePlate, SignalTarget, SecretTrigger, Named, LevelTransition,
KeyOf/LockedBy, Edible components, and Pressed / OnLevelTransition /
PendingDestroy tags. No system logic yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 2: CleanupSystem + World.Destroy-via-PendingDestroy discipline

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim/Systems/CleanupSystem.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/CleanupSystemTests.cs

  • Step 1: Write the failing test

Write muscleman-godot4/src/Muscleman.Sim.Tests/CleanupSystemTests.cs:

using FluentAssertions;
using Muscleman.Core.Grid;
using Muscleman.Sim;
using Muscleman.Sim.Components;
using Muscleman.Sim.Systems;
using Xunit;

namespace Muscleman.Sim.Tests;

public class CleanupSystemTests
{
    [Fact]
    public void Entities_tagged_PendingDestroy_are_destroyed()
    {
        var w = new World();
        var a = w.SpawnEmpty(new GridPos(1, 1));
        var b = w.SpawnEmpty(new GridPos(2, 2));
        var c = w.SpawnEmpty(new GridPos(3, 3));
        w.AddTag<PendingDestroy>(a);
        w.AddTag<PendingDestroy>(c);

        CleanupSystem.Tick(w);

        w.IsAlive(a).Should().BeFalse();
        w.IsAlive(b).Should().BeTrue();
        w.IsAlive(c).Should().BeFalse();
    }

    [Fact]
    public void Tick_with_no_pending_entities_is_a_no_op()
    {
        var w = new World();
        var a = w.SpawnEmpty(new GridPos(1, 1));

        CleanupSystem.Tick(w);

        w.IsAlive(a).Should().BeTrue();
    }
}
  • Step 2: Run test to verify it fails
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~CleanupSystemTests"

Expected: build error — CleanupSystem does not exist.

  • Step 3: Implement CleanupSystem

Write muscleman-godot4/src/Muscleman.Sim/Systems/CleanupSystem.cs:

using Friflo.Engine.ECS;
using Muscleman.Sim.Components;

namespace Muscleman.Sim.Systems;

public static class CleanupSystem
{
    /// <summary>
    /// Destroys every entity carrying the PendingDestroy tag. Run at the end
    /// of each tick, after all systems have had a chance to emit events
    /// referencing the entities still alive.
    /// </summary>
    public static void Tick(World w)
    {
        var ids = new List<int>();
        var query = w.Store.Query().AllTags(Tags.Get<PendingDestroy>());
        foreach (var entity in query.Entities)
            ids.Add(entity.Id);
        ids.Sort();
        foreach (var id in ids)
            w.Destroy(id);
    }
}
  • Step 4: Run tests to verify they pass
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~CleanupSystemTests"

Expected: 2 passed, 0 failed.

  • Step 5: Re-run the full sim test suite
dotnet test muscleman-godot4/Muscleman.sln

Expected: all existing tests still green.

  • Step 6: Commit
git add muscleman-godot4/src/Muscleman.Sim/Systems/CleanupSystem.cs \
        muscleman-godot4/src/Muscleman.Sim.Tests/CleanupSystemTests.cs
git commit -m "$(cat <<'EOF'
sim: add CleanupSystem — drain PendingDestroy tagged entities

End-of-tick destruction queue. Systems that need to remove an entity
tag PendingDestroy instead of calling Destroy directly; CleanupSystem
sorts by id (determinism) and destroys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 3: SinkingSystem

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim/Systems/SinkingSystem.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/SinkingSystemTests.cs

  • Step 1: Write the failing test

Write muscleman-godot4/src/Muscleman.Sim.Tests/SinkingSystemTests.cs:

using FluentAssertions;
using Muscleman.Core.Enums;
using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Muscleman.Sim;
using Muscleman.Sim.Components;
using Muscleman.Sim.Recipes;
using Muscleman.Sim.Systems;
using Xunit;

namespace Muscleman.Sim.Tests;

public class SinkingSystemTests
{
    [Fact]
    public void Heavy_sinkable_on_water_sinks_emits_Sank_and_becomes_walkable()
    {
        var w = new World();
        var water = RecipeFactory.Spawn(w, EntityRecipe.WaterCell, new GridPos(3, 3));
        var heavy = RecipeFactory.Spawn(w, EntityRecipe.HeavyBox, new GridPos(3, 3));
        var events = new EventBatch();

        SinkingSystem.Tick(w, events);

        w.HasTag<BlocksMovement>(heavy).Should().BeFalse();
        w.HasTag<BlocksPush>(heavy).Should().BeFalse();
        w.HasTag<Pushable>(heavy).Should().BeFalse();
        w.HasTag<Sinkable>(heavy).Should().BeFalse();
        w.HasTag<Heavy>(heavy).Should().BeFalse();
        w.HasTag<Walkable>(heavy).Should().BeTrue();
        events.OfType<Sank>().Should().ContainSingle().Which.EntityId.Should().Be(heavy);
    }

    [Fact]
    public void Heavy_not_on_water_is_unchanged()
    {
        var w = new World();
        var heavy = RecipeFactory.Spawn(w, EntityRecipe.HeavyBox, new GridPos(3, 3));
        var events = new EventBatch();

        SinkingSystem.Tick(w, events);

        w.HasTag<Heavy>(heavy).Should().BeTrue();
        w.HasTag<Walkable>(heavy).Should().BeFalse();
        events.Count.Should().Be(0);
    }

    [Fact]
    public void Multiple_heavies_on_water_all_sink_in_id_order()
    {
        var w = new World();
        var water1 = RecipeFactory.Spawn(w, EntityRecipe.WaterCell, new GridPos(1, 1));
        var water2 = RecipeFactory.Spawn(w, EntityRecipe.WaterCell, new GridPos(2, 2));
        var h1 = RecipeFactory.Spawn(w, EntityRecipe.HeavyBox, new GridPos(1, 1));
        var h2 = RecipeFactory.Spawn(w, EntityRecipe.HeavyBox, new GridPos(2, 2));
        var events = new EventBatch();

        SinkingSystem.Tick(w, events);

        events.OfType<Sank>().Select(s => s.EntityId).Should().Equal(h1, h2);
    }
}
  • Step 2: Run test to verify it fails
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~SinkingSystemTests"

Expected: build error — SinkingSystem does not exist.

  • Step 3: Implement SinkingSystem

Write muscleman-godot4/src/Muscleman.Sim/Systems/SinkingSystem.cs:

using Friflo.Engine.ECS;
using Muscleman.Core.Events;
using Muscleman.Sim.Components;

namespace Muscleman.Sim.Systems;

public static class SinkingSystem
{
    /// <summary>
    /// Sinks any entity carrying Heavy + Sinkable that shares a cell with a
    /// WaterCell. Sinking is one-way: removes blocking/pushable/heavy/sinkable
    /// tags, adds Walkable, emits Sank.
    /// </summary>
    public static void Tick(World w, EventBatch events)
    {
        var candidates = new List<int>();
        var query = w.Store.Query().AllTags(Tags.Get<Heavy, Sinkable>());
        foreach (var entity in query.Entities)
            candidates.Add(entity.Id);
        candidates.Sort();

        foreach (var id in candidates)
        {
            var pos = w.Get<Position>(id).Pos;
            var onWater = false;
            foreach (var other in w.Index.AllAt(pos))
            {
                if (other == id) continue;
                if (w.HasTag<WaterCell>(other)) { onWater = true; break; }
            }
            if (!onWater) continue;

            w.RemoveTag<BlocksMovement>(id);
            w.RemoveTag<BlocksPush>(id);
            w.RemoveTag<Pushable>(id);
            w.RemoveTag<Sinkable>(id);
            w.RemoveTag<Heavy>(id);
            w.AddTag<Walkable>(id);
            events.Add(new Sank(id));
        }
    }
}
  • Step 4: Run tests to verify they pass
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~SinkingSystemTests"

Expected: 3 passed, 0 failed.

  • Step 5: Re-run the full sim test suite
dotnet test muscleman-godot4/Muscleman.sln

Expected: all green. (Note: the existing SunkBoxWalkTests may need updating in a later task — they likely simulate sinking by hand. Verify they still pass; if they relied on a hand-coded sink, they continue to work but no longer need the manual setup. Do not touch them in this task; revisit in Task 14 if needed.)

  • Step 6: Commit
git add muscleman-godot4/src/Muscleman.Sim/Systems/SinkingSystem.cs \
        muscleman-godot4/src/Muscleman.Sim.Tests/SinkingSystemTests.cs
git commit -m "$(cat <<'EOF'
sim: add SinkingSystem — Heavy+Sinkable on WaterCell becomes Walkable

One-way: removes BlocksMovement, BlocksPush, Pushable, Sinkable, Heavy;
adds Walkable; emits Sank. Iteration sorted by EntityId for determinism.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 4: PressurePlateSystem

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim/Systems/PressurePlateSystem.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/PressurePlateSystemTests.cs

  • Step 1: Write the failing test

Write muscleman-godot4/src/Muscleman.Sim.Tests/PressurePlateSystemTests.cs:

using FluentAssertions;
using Muscleman.Core.Enums;
using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Muscleman.Sim;
using Muscleman.Sim.Components;
using Muscleman.Sim.Recipes;
using Muscleman.Sim.Systems;
using Xunit;

namespace Muscleman.Sim.Tests;

public class PressurePlateSystemTests
{
    private static int SpawnPlate(World w, GridPos pos, int group, bool requiresHeavy)
    {
        var id = w.SpawnEmpty(pos);
        w.Set(id, new PressurePlate(group, requiresHeavy));
        w.AddTag<Walkable>(id);
        return id;
    }

    [Fact]
    public void Light_plate_activates_when_player_steps_on_it()
    {
        var w = new World();
        var plate = SpawnPlate(w, new GridPos(2, 2), group: 1, requiresHeavy: false);
        var player = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(2, 2));
        var events = new EventBatch();

        PressurePlateSystem.Tick(w, events);

        w.HasTag<Pressed>(plate).Should().BeTrue();
        events.OfType<PlatePressed>().Should().ContainSingle().Which.PlateId.Should().Be(plate);
    }

    [Fact]
    public void Light_plate_activates_when_a_pushable_box_is_on_it()
    {
        var w = new World();
        var plate = SpawnPlate(w, new GridPos(2, 2), group: 1, requiresHeavy: false);
        var box = RecipeFactory.Spawn(w, EntityRecipe.Box, new GridPos(2, 2));
        var events = new EventBatch();

        PressurePlateSystem.Tick(w, events);

        w.HasTag<Pressed>(plate).Should().BeTrue();
    }

    [Fact]
    public void Heavy_plate_ignores_player()
    {
        var w = new World();
        var plate = SpawnPlate(w, new GridPos(2, 2), group: 1, requiresHeavy: true);
        var player = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(2, 2));
        var events = new EventBatch();

        PressurePlateSystem.Tick(w, events);

        w.HasTag<Pressed>(plate).Should().BeFalse();
        events.OfType<PlatePressed>().Should().BeEmpty();
    }

    [Fact]
    public void Heavy_plate_ignores_light_box()
    {
        var w = new World();
        var plate = SpawnPlate(w, new GridPos(2, 2), group: 1, requiresHeavy: true);
        var lightBox = RecipeFactory.Spawn(w, EntityRecipe.Box, new GridPos(2, 2));
        var events = new EventBatch();

        PressurePlateSystem.Tick(w, events);

        w.HasTag<Pressed>(plate).Should().BeFalse();
    }

    [Fact]
    public void Heavy_plate_activates_with_heavy_box()
    {
        var w = new World();
        var plate = SpawnPlate(w, new GridPos(2, 2), group: 1, requiresHeavy: true);
        var heavy = RecipeFactory.Spawn(w, EntityRecipe.HeavyBox, new GridPos(2, 2));
        var events = new EventBatch();

        PressurePlateSystem.Tick(w, events);

        w.HasTag<Pressed>(plate).Should().BeTrue();
    }

    [Fact]
    public void Plate_emits_PlateReleased_when_previously_pressed_and_now_empty()
    {
        var w = new World();
        var plate = SpawnPlate(w, new GridPos(2, 2), group: 1, requiresHeavy: false);
        w.AddTag<Pressed>(plate); // simulate previously pressed
        var events = new EventBatch();

        PressurePlateSystem.Tick(w, events);

        w.HasTag<Pressed>(plate).Should().BeFalse();
        events.OfType<PlateReleased>().Should().ContainSingle().Which.PlateId.Should().Be(plate);
    }

    [Fact]
    public void Plate_already_pressed_with_occupant_does_not_re_emit()
    {
        var w = new World();
        var plate = SpawnPlate(w, new GridPos(2, 2), group: 1, requiresHeavy: false);
        w.AddTag<Pressed>(plate);
        var box = RecipeFactory.Spawn(w, EntityRecipe.Box, new GridPos(2, 2));
        var events = new EventBatch();

        PressurePlateSystem.Tick(w, events);

        w.HasTag<Pressed>(plate).Should().BeTrue();
        events.OfType<PlatePressed>().Should().BeEmpty();
    }
}
  • Step 2: Run test to verify it fails
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~PressurePlateSystemTests"

Expected: build error — PressurePlateSystem does not exist.

  • Step 3: Implement PressurePlateSystem

Write muscleman-godot4/src/Muscleman.Sim/Systems/PressurePlateSystem.cs:

using Friflo.Engine.ECS;
using Muscleman.Core.Events;
using Muscleman.Sim.Components;

namespace Muscleman.Sim.Systems;

public static class PressurePlateSystem
{
    /// <summary>
    /// Recomputes Pressed state for every entity with a PressurePlate component.
    /// Emits PlatePressed / PlateReleased on transitions.
    /// </summary>
    public static void Tick(World w, EventBatch events)
    {
        var plates = new List<int>();
        var query = w.Store.Query<PressurePlate>();
        foreach (var entity in query.Entities)
            plates.Add(entity.Id);
        plates.Sort();

        foreach (var id in plates)
        {
            var plate = w.Get<PressurePlate>(id);
            var pos = w.Get<Position>(id).Pos;
            var nowPressed = IsPressedNow(w, id, pos, plate.RequiresHeavy);
            var wasPressed = w.HasTag<Pressed>(id);

            if (nowPressed && !wasPressed)
            {
                w.AddTag<Pressed>(id);
                events.Add(new PlatePressed(id));
            }
            else if (!nowPressed && wasPressed)
            {
                w.RemoveTag<Pressed>(id);
                events.Add(new PlateReleased(id));
            }
        }
    }

    private static bool IsPressedNow(World w, int plateId, Muscleman.Core.Grid.GridPos pos, bool requiresHeavy)
    {
        foreach (var other in w.Index.AllAt(pos))
        {
            if (other == plateId) continue;
            if (requiresHeavy)
            {
                if (w.HasTag<Heavy>(other)) return true;
            }
            else
            {
                if (w.HasTag<Player>(other) || w.HasTag<Pushable>(other)) return true;
            }
        }
        return false;
    }
}
  • Step 4: Run tests to verify they pass
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~PressurePlateSystemTests"

Expected: 7 passed, 0 failed.

  • Step 5: Re-run the full suite
dotnet test muscleman-godot4/Muscleman.sln

Expected: all green.

  • Step 6: Commit
git add muscleman-godot4/src/Muscleman.Sim/Systems/PressurePlateSystem.cs \
        muscleman-godot4/src/Muscleman.Sim.Tests/PressurePlateSystemTests.cs
git commit -m "$(cat <<'EOF'
sim: add PressurePlateSystem — Pressed tag and transition events

Light plate = player or Pushable in cell. Heavy plate = Heavy in cell.
Emits PlatePressed / PlateReleased on transitions; idempotent while held.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 5: SecretTriggerSystem

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim/Systems/SecretTriggerSystem.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/SecretTriggerSystemTests.cs

  • Step 1: Write the failing test

Write muscleman-godot4/src/Muscleman.Sim.Tests/SecretTriggerSystemTests.cs:

using FluentAssertions;
using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Muscleman.Sim;
using Muscleman.Sim.Components;
using Muscleman.Sim.Systems;
using Xunit;

namespace Muscleman.Sim.Tests;

public class SecretTriggerSystemTests
{
    private static int SpawnTrigger(World w, GridPos pos, string name, int group)
    {
        var id = w.SpawnEmpty(pos);
        w.Set(id, new SecretTrigger(name, group));
        return id;
    }

    private static int SpawnNamed(World w, GridPos pos, string name)
    {
        var id = w.SpawnEmpty(pos);
        w.Set(id, new Named(name));
        return id;
    }

    [Fact]
    public void Trigger_becomes_Pressed_when_named_entity_is_in_its_cell()
    {
        var w = new World();
        var trigger = SpawnTrigger(w, new GridPos(5, 5), "duck", group: 1);
        var duck = SpawnNamed(w, new GridPos(5, 5), "duck");
        var events = new EventBatch();

        SecretTriggerSystem.Tick(w, events);

        w.HasTag<Pressed>(trigger).Should().BeTrue();
    }

    [Fact]
    public void Trigger_becomes_unpressed_when_named_entity_leaves()
    {
        var w = new World();
        var trigger = SpawnTrigger(w, new GridPos(5, 5), "duck", group: 1);
        w.AddTag<Pressed>(trigger);
        var events = new EventBatch();

        SecretTriggerSystem.Tick(w, events);

        w.HasTag<Pressed>(trigger).Should().BeFalse();
    }

    [Fact]
    public void Trigger_ignores_entities_with_different_name()
    {
        var w = new World();
        var trigger = SpawnTrigger(w, new GridPos(5, 5), "duck", group: 1);
        var goose = SpawnNamed(w, new GridPos(5, 5), "goose");
        var events = new EventBatch();

        SecretTriggerSystem.Tick(w, events);

        w.HasTag<Pressed>(trigger).Should().BeFalse();
    }
}
  • Step 2: Run test to verify it fails
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~SecretTriggerSystemTests"

Expected: build error — SecretTriggerSystem does not exist.

  • Step 3: Implement SecretTriggerSystem

Write muscleman-godot4/src/Muscleman.Sim/Systems/SecretTriggerSystem.cs:

using Friflo.Engine.ECS;
using Muscleman.Core.Events;
using Muscleman.Sim.Components;

namespace Muscleman.Sim.Systems;

public static class SecretTriggerSystem
{
    /// <summary>
    /// Sets the Pressed tag on each SecretTrigger entity whose cell contains
    /// an entity with a Named component matching the trigger's EntityName.
    /// Emits no events; SignalSystem aggregates trigger state into group state.
    /// </summary>
    public static void Tick(World w, EventBatch events)
    {
        var triggers = new List<int>();
        var query = w.Store.Query<SecretTrigger>();
        foreach (var entity in query.Entities)
            triggers.Add(entity.Id);
        triggers.Sort();

        foreach (var id in triggers)
        {
            var trigger = w.Get<SecretTrigger>(id);
            var pos = w.Get<Position>(id).Pos;
            var matched = false;
            foreach (var other in w.Index.AllAt(pos))
            {
                if (other == id) continue;
                if (!w.Has<Named>(other)) continue;
                if (w.Get<Named>(other).Name == trigger.EntityName) { matched = true; break; }
            }
            if (matched) w.AddTag<Pressed>(id);
            else w.RemoveTag<Pressed>(id);
        }
    }
}
  • Step 4: Run tests to verify they pass
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~SecretTriggerSystemTests"

Expected: 3 passed, 0 failed.

  • Step 5: Commit
git add muscleman-godot4/src/Muscleman.Sim/Systems/SecretTriggerSystem.cs \
        muscleman-godot4/src/Muscleman.Sim.Tests/SecretTriggerSystemTests.cs
git commit -m "$(cat <<'EOF'
sim: add SecretTriggerSystem — Named entity at trigger cell sets Pressed

Reuses Pressed tag so SignalSystem aggregates plates and triggers
indiscriminately. No events emitted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 6: SignalSystem and World.ActiveSignalGroups

Files:

  • Modify: muscleman-godot4/src/Muscleman.Sim/World.cs
  • Create: muscleman-godot4/src/Muscleman.Sim/Systems/SignalSystem.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/SignalSystemTests.cs

  • Step 1: Add ActiveSignalGroups (and Counters) to World

Modify muscleman-godot4/src/Muscleman.Sim/World.cs. Inside the World class body, alongside Index, add:

public HashSet<int> ActiveSignalGroups { get; } = new();
public Dictionary<Muscleman.Core.Enums.GlobalCounter, int> Counters { get; } = new();

Counters is added now (rather than in a later task) so all the resource additions to World happen in one commit.

  • Step 2: Write the failing test

Write muscleman-godot4/src/Muscleman.Sim.Tests/SignalSystemTests.cs:

using FluentAssertions;
using Muscleman.Core.Grid;
using Muscleman.Sim;
using Muscleman.Sim.Components;
using Muscleman.Sim.Systems;
using Xunit;

namespace Muscleman.Sim.Tests;

public class SignalSystemTests
{
    private static int SpawnPlate(World w, GridPos pos, int group)
    {
        var id = w.SpawnEmpty(pos);
        w.Set(id, new PressurePlate(group, requiresHeavy: false));
        return id;
    }

    [Fact]
    public void Group_is_active_when_any_plate_in_that_group_is_pressed()
    {
        var w = new World();
        var p1 = SpawnPlate(w, new GridPos(1, 1), group: 7);
        var p2 = SpawnPlate(w, new GridPos(2, 2), group: 7);
        w.AddTag<Pressed>(p1); // only one pressed

        SignalSystem.Tick(w);

        w.ActiveSignalGroups.Should().Contain(7);
    }

    [Fact]
    public void Group_is_inactive_when_no_source_is_pressed()
    {
        var w = new World();
        var p1 = SpawnPlate(w, new GridPos(1, 1), group: 9);

        SignalSystem.Tick(w);

        w.ActiveSignalGroups.Should().NotContain(9);
    }

    [Fact]
    public void Active_groups_set_is_cleared_each_tick()
    {
        var w = new World();
        w.ActiveSignalGroups.Add(99); // stale from a prior tick

        SignalSystem.Tick(w);

        w.ActiveSignalGroups.Should().NotContain(99);
    }

    [Fact]
    public void Secret_trigger_contributes_to_its_group()
    {
        var w = new World();
        var trig = w.SpawnEmpty(new GridPos(3, 3));
        w.Set(trig, new SecretTrigger("duck", group: 4));
        w.AddTag<Pressed>(trig);

        SignalSystem.Tick(w);

        w.ActiveSignalGroups.Should().Contain(4);
    }
}
  • Step 3: Run test to verify it fails
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~SignalSystemTests"

Expected: build error — SignalSystem does not exist.

  • Step 4: Implement SignalSystem

Write muscleman-godot4/src/Muscleman.Sim/Systems/SignalSystem.cs:

using Friflo.Engine.ECS;
using Muscleman.Sim.Components;

namespace Muscleman.Sim.Systems;

public static class SignalSystem
{
    /// <summary>
    /// Derives the active signal groups for this tick (OR aggregation):
    /// a group is active iff any PressurePlate or SecretTrigger entity
    /// in that group currently carries the Pressed tag.
    /// Stores the result in World.ActiveSignalGroups, replacing prior tick state.
    /// </summary>
    public static void Tick(World w)
    {
        w.ActiveSignalGroups.Clear();

        var plates = w.Store.Query<PressurePlate>();
        foreach (var entity in plates.Entities)
        {
            if (!entity.Tags.Has<Pressed>()) continue;
            w.ActiveSignalGroups.Add(entity.GetComponent<PressurePlate>().Group);
        }

        var triggers = w.Store.Query<SecretTrigger>();
        foreach (var entity in triggers.Entities)
        {
            if (!entity.Tags.Has<Pressed>()) continue;
            w.ActiveSignalGroups.Add(entity.GetComponent<SecretTrigger>().Group);
        }
    }
}
  • Step 5: Run tests to verify they pass
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~SignalSystemTests"

Expected: 4 passed, 0 failed.

  • Step 6: Commit
git add muscleman-godot4/src/Muscleman.Sim/World.cs \
        muscleman-godot4/src/Muscleman.Sim/Systems/SignalSystem.cs \
        muscleman-godot4/src/Muscleman.Sim.Tests/SignalSystemTests.cs
git commit -m "$(cat <<'EOF'
sim: add SignalSystem + World.ActiveSignalGroups / World.Counters

OR aggregation across PressurePlate and SecretTrigger sources. Resets
the active-groups set each tick. Also adds the Counters resource ahead
of fruit-eating work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 7: DoorSystem (with shared OpenDoor / CloseDoor helpers)

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim/Systems/DoorSystem.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/DoorSystemTests.cs

  • Step 1: Write the failing test

Write muscleman-godot4/src/Muscleman.Sim.Tests/DoorSystemTests.cs:

using FluentAssertions;
using Muscleman.Core.Enums;
using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Muscleman.Sim;
using Muscleman.Sim.Components;
using Muscleman.Sim.Systems;
using Xunit;

namespace Muscleman.Sim.Tests;

public class DoorSystemTests
{
    private static int SpawnDoor(World w, GridPos pos, int group, SignalBehavior behavior, bool initiallyOpen = false)
    {
        var id = w.SpawnEmpty(pos);
        w.AddTag<Door>(id);
        w.Set(id, new SignalTarget(group, behavior));
        if (initiallyOpen)
        {
            w.AddTag<DoorOpen>(id);
        }
        else
        {
            w.AddTag<BlocksMovement>(id);
            w.AddTag<BlocksPush>(id);
        }
        return id;
    }

    [Fact]
    public void OpenWhilePressed_opens_when_group_active()
    {
        var w = new World();
        var door = SpawnDoor(w, new GridPos(3, 3), group: 1, SignalBehavior.OpenWhilePressed);
        w.ActiveSignalGroups.Add(1);
        var events = new EventBatch();

        DoorSystem.Tick(w, events);

        w.HasTag<DoorOpen>(door).Should().BeTrue();
        w.HasTag<BlocksMovement>(door).Should().BeFalse();
        w.HasTag<BlocksPush>(door).Should().BeFalse();
        events.OfType<DoorOpened>().Should().ContainSingle().Which.DoorId.Should().Be(door);
    }

    [Fact]
    public void OpenWhilePressed_closes_when_group_inactive()
    {
        var w = new World();
        var door = SpawnDoor(w, new GridPos(3, 3), group: 1, SignalBehavior.OpenWhilePressed, initiallyOpen: true);
        var events = new EventBatch();

        DoorSystem.Tick(w, events);

        w.HasTag<DoorOpen>(door).Should().BeFalse();
        w.HasTag<BlocksMovement>(door).Should().BeTrue();
        w.HasTag<BlocksPush>(door).Should().BeTrue();
        events.OfType<DoorClosed>().Should().ContainSingle().Which.DoorId.Should().Be(door);
    }

    [Fact]
    public void OpenOnPressed_latches_open()
    {
        var w = new World();
        var door = SpawnDoor(w, new GridPos(3, 3), group: 1, SignalBehavior.OpenOnPressed);
        w.ActiveSignalGroups.Add(1);
        var events = new EventBatch();

        DoorSystem.Tick(w, events);
        w.HasTag<DoorOpen>(door).Should().BeTrue();

        // Next tick: group goes inactive, door must stay open and emit nothing.
        w.ActiveSignalGroups.Clear();
        var events2 = new EventBatch();
        DoorSystem.Tick(w, events2);

        w.HasTag<DoorOpen>(door).Should().BeTrue();
        events2.Count.Should().Be(0);
    }

    [Fact]
    public void OpenWhileReleased_is_open_when_group_inactive()
    {
        var w = new World();
        var door = SpawnDoor(w, new GridPos(3, 3), group: 1, SignalBehavior.OpenWhileReleased);
        var events = new EventBatch();

        DoorSystem.Tick(w, events);

        w.HasTag<DoorOpen>(door).Should().BeTrue();
        events.OfType<DoorOpened>().Should().ContainSingle();
    }

    [Fact]
    public void OpenWhileReleased_closes_when_group_active()
    {
        var w = new World();
        var door = SpawnDoor(w, new GridPos(3, 3), group: 1, SignalBehavior.OpenWhileReleased, initiallyOpen: true);
        w.ActiveSignalGroups.Add(1);
        var events = new EventBatch();

        DoorSystem.Tick(w, events);

        w.HasTag<DoorOpen>(door).Should().BeFalse();
        events.OfType<DoorClosed>().Should().ContainSingle();
    }

    [Fact]
    public void Door_without_SignalTarget_is_untouched()
    {
        var w = new World();
        var id = w.SpawnEmpty(new GridPos(3, 3));
        w.AddTag<Door>(id);
        w.AddTag<BlocksMovement>(id);
        w.AddTag<BlocksPush>(id);
        var events = new EventBatch();

        DoorSystem.Tick(w, events);

        w.HasTag<DoorOpen>(id).Should().BeFalse();
        w.HasTag<BlocksMovement>(id).Should().BeTrue();
        events.Count.Should().Be(0);
    }

    [Fact]
    public void OpenDoor_helper_opens_a_locked_door_and_emits_DoorOpened()
    {
        var w = new World();
        var id = w.SpawnEmpty(new GridPos(3, 3));
        w.AddTag<Door>(id);
        w.Set(id, new LockedBy(KeyColor.Blue));
        w.AddTag<BlocksMovement>(id);
        w.AddTag<BlocksPush>(id);
        var events = new EventBatch();

        DoorSystem.OpenDoor(w, id, events);

        w.HasTag<DoorOpen>(id).Should().BeTrue();
        w.HasTag<BlocksMovement>(id).Should().BeFalse();
        w.HasTag<BlocksPush>(id).Should().BeFalse();
        w.Has<LockedBy>(id).Should().BeFalse();
        events.OfType<DoorOpened>().Should().ContainSingle().Which.DoorId.Should().Be(id);
    }
}
  • Step 2: Run test to verify it fails
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~DoorSystemTests"

Expected: build error — DoorSystem does not exist.

  • Step 3: Implement DoorSystem

Write muscleman-godot4/src/Muscleman.Sim/Systems/DoorSystem.cs:

using Friflo.Engine.ECS;
using Muscleman.Core.Enums;
using Muscleman.Core.Events;
using Muscleman.Sim.Components;

namespace Muscleman.Sim.Systems;

public static class DoorSystem
{
    /// <summary>
    /// Reads SignalTarget(group, behavior) on Door entities and applies the
    /// desired open/closed state from World.ActiveSignalGroups. Doors without
    /// SignalTarget are untouched.
    /// </summary>
    public static void Tick(World w, EventBatch events)
    {
        var ids = new List<int>();
        var query = w.Store.Query<SignalTarget>().AllTags(Tags.Get<Door>());
        foreach (var entity in query.Entities)
            ids.Add(entity.Id);
        ids.Sort();

        foreach (var id in ids)
        {
            var target = w.Get<SignalTarget>(id);
            var active = w.ActiveSignalGroups.Contains(target.Group);
            var isOpen = w.HasTag<DoorOpen>(id);

            switch (target.Behavior)
            {
                case SignalBehavior.OpenWhilePressed:
                    if (active && !isOpen) OpenDoor(w, id, events);
                    else if (!active && isOpen) CloseDoor(w, id, events);
                    break;
                case SignalBehavior.OpenOnPressed:
                    if (active && !isOpen) OpenDoor(w, id, events);
                    break;
                case SignalBehavior.OpenWhileReleased:
                    if (!active && !isOpen) OpenDoor(w, id, events);
                    else if (active && isOpen) CloseDoor(w, id, events);
                    break;
            }
        }
    }

    /// <summary>Opens a door: drops LockedBy if present, removes BlocksMovement/BlocksPush, adds DoorOpen, emits DoorOpened.</summary>
    public static void OpenDoor(World w, int doorId, EventBatch events)
    {
        if (w.Has<LockedBy>(doorId)) w.Remove<LockedBy>(doorId);
        w.RemoveTag<BlocksMovement>(doorId);
        w.RemoveTag<BlocksPush>(doorId);
        w.AddTag<DoorOpen>(doorId);
        events.Add(new DoorOpened(doorId));
    }

    /// <summary>Closes a door: adds BlocksMovement/BlocksPush, removes DoorOpen, emits DoorClosed.</summary>
    public static void CloseDoor(World w, int doorId, EventBatch events)
    {
        w.AddTag<BlocksMovement>(doorId);
        w.AddTag<BlocksPush>(doorId);
        w.RemoveTag<DoorOpen>(doorId);
        events.Add(new DoorClosed(doorId));
    }
}
  • Step 4: Run tests to verify they pass
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~DoorSystemTests"

Expected: 7 passed, 0 failed.

  • Step 5: Commit
git add muscleman-godot4/src/Muscleman.Sim/Systems/DoorSystem.cs \
        muscleman-godot4/src/Muscleman.Sim.Tests/DoorSystemTests.cs
git commit -m "$(cat <<'EOF'
sim: add DoorSystem — three SignalBehaviors + OpenDoor/CloseDoor helpers

Behaviors: OpenWhilePressed (auto-close), OpenOnPressed (latch open),
OpenWhileReleased (inverted). Helpers also serve the key-into-door path
where doors open from the player phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 8: LevelTransitionSystem

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim/Systems/LevelTransitionSystem.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/LevelTransitionSystemTests.cs

  • Step 1: Write the failing test

Write muscleman-godot4/src/Muscleman.Sim.Tests/LevelTransitionSystemTests.cs:

using FluentAssertions;
using Muscleman.Core.Enums;
using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Muscleman.Sim;
using Muscleman.Sim.Components;
using Muscleman.Sim.Recipes;
using Muscleman.Sim.Systems;
using Xunit;

namespace Muscleman.Sim.Tests;

public class LevelTransitionSystemTests
{
    private static int SpawnStairs(World w, GridPos pos, string target, string spawn)
    {
        var id = w.SpawnEmpty(pos);
        w.Set(id, new LevelTransition(target, spawn));
        w.AddTag<Walkable>(id);
        return id;
    }

    [Fact]
    public void Player_on_stairs_emits_transition_once_per_visit()
    {
        var w = new World();
        var player = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(5, 5));
        var stairs = SpawnStairs(w, new GridPos(5, 5), target: "level2", spawn: "start");
        var events = new EventBatch();

        LevelTransitionSystem.Tick(w, player, events);

        var transitions = events.OfType<LevelTransitionEvent>().ToList();
        transitions.Should().ContainSingle();
        transitions[0].StairsId.Should().Be(stairs);
        transitions[0].TargetLevelId.Should().Be("level2");
        transitions[0].TargetSpawnId.Should().Be("start");
        w.HasTag<OnLevelTransition>(player).Should().BeTrue();
    }

    [Fact]
    public void Player_standing_on_stairs_does_not_re_emit()
    {
        var w = new World();
        var player = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(5, 5));
        var stairs = SpawnStairs(w, new GridPos(5, 5), "level2", "start");
        var events1 = new EventBatch();
        LevelTransitionSystem.Tick(w, player, events1);

        var events2 = new EventBatch();
        LevelTransitionSystem.Tick(w, player, events2);

        events2.OfType<LevelTransitionEvent>().Should().BeEmpty();
    }

    [Fact]
    public void Leaving_stairs_clears_the_debounce_tag()
    {
        var w = new World();
        var player = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(5, 5));
        SpawnStairs(w, new GridPos(5, 5), "level2", "start");
        LevelTransitionSystem.Tick(w, player, new EventBatch());

        // Player walks off.
        w.Move(player, new GridPos(6, 5));
        LevelTransitionSystem.Tick(w, player, new EventBatch());

        w.HasTag<OnLevelTransition>(player).Should().BeFalse();
    }

    [Fact]
    public void Re_entering_stairs_emits_again()
    {
        var w = new World();
        var player = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(5, 5));
        SpawnStairs(w, new GridPos(5, 5), "level2", "start");
        LevelTransitionSystem.Tick(w, player, new EventBatch());
        w.Move(player, new GridPos(6, 5));
        LevelTransitionSystem.Tick(w, player, new EventBatch());
        w.Move(player, new GridPos(5, 5));
        var events = new EventBatch();

        LevelTransitionSystem.Tick(w, player, events);

        events.OfType<LevelTransitionEvent>().Should().ContainSingle();
    }
}
  • Step 2: Run test to verify it fails
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~LevelTransitionSystemTests"

Expected: build error — LevelTransitionSystem does not exist.

  • Step 3: Implement LevelTransitionSystem

Write muscleman-godot4/src/Muscleman.Sim/Systems/LevelTransitionSystem.cs:

using Muscleman.Core.Events;
using Muscleman.Sim.Components;

namespace Muscleman.Sim.Systems;

public static class LevelTransitionSystem
{
    /// <summary>
    /// Emits LevelTransitionEvent once per visit when the player's cell
    /// contains a LevelTransition entity. Uses the OnLevelTransition tag
    /// to debounce: tag set on emission, cleared when the player leaves
    /// the stairs cell.
    /// </summary>
    public static void Tick(World w, int playerId, EventBatch events)
    {
        var pos = w.Get<Position>(playerId).Pos;
        int? stairsId = null;
        foreach (var other in w.Index.AllAt(pos))
        {
            if (other == playerId) continue;
            if (w.Has<LevelTransition>(other)) { stairsId = other; break; }
        }

        if (stairsId is null)
        {
            if (w.HasTag<OnLevelTransition>(playerId))
                w.RemoveTag<OnLevelTransition>(playerId);
            return;
        }

        if (w.HasTag<OnLevelTransition>(playerId)) return; // already triggered this visit

        var transition = w.Get<LevelTransition>(stairsId.Value);
        events.Add(new LevelTransitionEvent(stairsId.Value, transition.TargetLevelId, transition.TargetSpawnId));
        w.AddTag<OnLevelTransition>(playerId);
    }
}
  • Step 4: Run tests to verify they pass
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~LevelTransitionSystemTests"

Expected: 4 passed, 0 failed.

  • Step 5: Commit
git add muscleman-godot4/src/Muscleman.Sim/Systems/LevelTransitionSystem.cs \
        muscleman-godot4/src/Muscleman.Sim.Tests/LevelTransitionSystemTests.cs
git commit -m "$(cat <<'EOF'
sim: add LevelTransitionSystem — emit-once-per-visit on stairs

Debounced via OnLevelTransition tag; cleared when the player leaves
the stairs cell, so re-entering re-emits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 9: Wire the env phase into StepRunner.Step

Files:

  • Modify: muscleman-godot4/src/Muscleman.Sim/StepRunner.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/StepRunnerEnvPhaseTests.cs

  • Step 1: Write the failing test (integration through StepRunner)

Write muscleman-godot4/src/Muscleman.Sim.Tests/StepRunnerEnvPhaseTests.cs:

using FluentAssertions;
using Muscleman.Core.Enums;
using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Muscleman.Core.Input;
using Muscleman.Sim;
using Muscleman.Sim.Components;
using Muscleman.Sim.Recipes;
using Xunit;

namespace Muscleman.Sim.Tests;

public class StepRunnerEnvPhaseTests
{
    [Fact]
    public void Pushing_heavy_onto_water_sinks_it_in_the_same_tick()
    {
        var w = new World();
        var player = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(5, 5));
        var heavy = RecipeFactory.Spawn(w, EntityRecipe.HeavyBox, new GridPos(6, 5));
        var water = RecipeFactory.Spawn(w, EntityRecipe.WaterCell, new GridPos(7, 5));

        var batch = StepRunner.Step(w, player, new Input.Move(GridDir.Right));

        w.Get<Position>(heavy).Pos.Should().Be(new GridPos(7, 5));
        w.HasTag<Walkable>(heavy).Should().BeTrue();
        batch.OfType<Sank>().Should().ContainSingle().Which.EntityId.Should().Be(heavy);
    }
}
  • Step 2: Run test to verify it fails
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~StepRunnerEnvPhaseTests"

Expected: test fails — Sank is not emitted because SinkingSystem is not yet wired into StepRunner.

  • Step 3: Wire all env-phase systems into StepRunner

Replace the body of muscleman-godot4/src/Muscleman.Sim/StepRunner.cs with:

using Muscleman.Core.Events;
using Muscleman.Core.Input;
using Muscleman.Sim.Systems;

namespace Muscleman.Sim;

public static class StepRunner
{
    /// <summary>
    /// Public sim-step entry point. Resolves one player input and runs the
    /// environmental + cleanup phases, returning the resulting EventBatch.
    /// </summary>
    public static EventBatch Step(World w, int playerId, Input input)
    {
        var events = new EventBatch();

        // Player phase
        PlayerActionSystem.Resolve(w, playerId, input, events);

        // (Actor phase — empty in this slice)

        // Environmental phase
        SinkingSystem.Tick(w, events);
        PressurePlateSystem.Tick(w, events);
        SecretTriggerSystem.Tick(w, events);
        SignalSystem.Tick(w);
        DoorSystem.Tick(w, events);
        LevelTransitionSystem.Tick(w, playerId, events);

        // Cleanup phase
        CleanupSystem.Tick(w);

        return events;
    }
}
  • Step 4: Run the new test plus the full suite
dotnet test muscleman-godot4/Muscleman.sln

Expected: all green, including the new integration test and all the per-system tests from Tasks 2–8.

  • Step 5: Commit
git add muscleman-godot4/src/Muscleman.Sim/StepRunner.cs \
        muscleman-godot4/src/Muscleman.Sim.Tests/StepRunnerEnvPhaseTests.cs
git commit -m "$(cat <<'EOF'
sim: wire env + cleanup phases into StepRunner

Order: Player → Sinking → PressurePlate → SecretTrigger → Signal →
Door → LevelTransition → Cleanup. Integration test covers push-heavy-
onto-water sinking in a single tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 10: Extend RecipeFactory with the new entity recipes

Files:

  • Modify: muscleman-godot4/src/Muscleman.Sim/Recipes/RecipeFactory.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/RecipeFactoryEnvTests.cs

  • Step 1: Write the failing test

Write muscleman-godot4/src/Muscleman.Sim.Tests/RecipeFactoryEnvTests.cs:

using FluentAssertions;
using Muscleman.Core.Enums;
using Muscleman.Core.Grid;
using Muscleman.Sim;
using Muscleman.Sim.Components;
using Muscleman.Sim.Recipes;
using Xunit;

namespace Muscleman.Sim.Tests;

public class RecipeFactoryEnvTests
{
    [Fact]
    public void DoorBlue_recipe_creates_locked_blocking_door()
    {
        var w = new World();
        var id = RecipeFactory.Spawn(w, EntityRecipe.DoorBlue, new GridPos(1, 1));

        w.HasTag<Door>(id).Should().BeTrue();
        w.HasTag<BlocksMovement>(id).Should().BeTrue();
        w.HasTag<BlocksPush>(id).Should().BeTrue();
        w.HasTag<DoorOpen>(id).Should().BeFalse();
        w.Get<LockedBy>(id).Color.Should().Be(KeyColor.Blue);
    }

    [Fact]
    public void KeyRed_recipe_carries_KeyOf_red_and_is_liftable()
    {
        var w = new World();
        var id = RecipeFactory.Spawn(w, EntityRecipe.KeyRed, new GridPos(1, 1));

        w.Get<KeyOf>(id).Color.Should().Be(KeyColor.Red);
        w.HasTag<Pushable>(id).Should().BeTrue();
        w.HasTag<Liftable>(id).Should().BeTrue();
        w.HasTag<BlocksMovement>(id).Should().BeTrue();
        w.HasTag<BlocksPush>(id).Should().BeTrue();
    }

    [Fact]
    public void PressurePlateBlue_recipe_is_walkable_with_group_set()
    {
        var w = new World();
        var id = RecipeFactory.Spawn(w, EntityRecipe.PressurePlateBlue, new GridPos(1, 1));

        var plate = w.Get<PressurePlate>(id);
        plate.RequiresHeavy.Should().BeFalse();
        plate.Group.Should().NotBe(0);
        w.HasTag<Walkable>(id).Should().BeTrue();
        w.HasTag<BlocksMovement>(id).Should().BeFalse();
    }

    [Fact]
    public void PressurePlateHeavy_recipe_requires_heavy()
    {
        var w = new World();
        var id = RecipeFactory.Spawn(w, EntityRecipe.PressurePlateHeavy, new GridPos(1, 1));

        w.Get<PressurePlate>(id).RequiresHeavy.Should().BeTrue();
    }

    [Fact]
    public void StairsUp_recipe_carries_LevelTransition_and_is_walkable()
    {
        var w = new World();
        var id = RecipeFactory.Spawn(w, EntityRecipe.StairsUp, new GridPos(1, 1));

        w.Has<LevelTransition>(id).Should().BeTrue();
        w.HasTag<Walkable>(id).Should().BeTrue();
    }

    [Fact]
    public void Apple_recipe_is_pushable_edible_with_ApplesEaten_counter()
    {
        var w = new World();
        var id = RecipeFactory.Spawn(w, EntityRecipe.Apple, new GridPos(1, 1));

        w.Get<Edible>(id).Counter.Should().Be(GlobalCounter.ApplesEaten);
        w.HasTag<Pushable>(id).Should().BeTrue();
        w.HasTag<Liftable>(id).Should().BeTrue();
    }

    [Fact]
    public void Duck_recipe_is_Named_duck()
    {
        var w = new World();
        var id = RecipeFactory.Spawn(w, EntityRecipe.Duck, new GridPos(1, 1));

        w.Get<Named>(id).Name.Should().Be("duck");
    }

    [Fact]
    public void SecretDoor_recipe_has_Door_tag_and_SignalTarget()
    {
        var w = new World();
        var id = RecipeFactory.Spawn(w, EntityRecipe.SecretDoor, new GridPos(1, 1));

        w.HasTag<Door>(id).Should().BeTrue();
        w.Has<SignalTarget>(id).Should().BeTrue();
        w.Get<SignalTarget>(id).Behavior.Should().Be(SignalBehavior.OpenWhilePressed);
    }
}
  • Step 2: Run the test to verify it fails
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~RecipeFactoryEnvTests"

Expected: NotImplementedException for every new recipe.

  • Step 3: Extend RecipeFactory.Apply

Replace the body of Apply in muscleman-godot4/src/Muscleman.Sim/Recipes/RecipeFactory.cs so the file becomes:

using Muscleman.Core.Enums;
using Muscleman.Core.Grid;
using Muscleman.Sim.Components;

namespace Muscleman.Sim.Recipes;

public static class RecipeFactory
{
    /// <summary>
    /// Spawns an entity of the given recipe at the given grid position.
    /// Returns the new entity's id.
    /// </summary>
    public static int Spawn(World w, EntityRecipe recipe, GridPos pos)
    {
        var id = w.SpawnEmpty(pos);
        Apply(w, id, recipe);
        return id;
    }

    private static void Apply(World w, int id, EntityRecipe recipe)
    {
        switch (recipe)
        {
            case EntityRecipe.Player:
                w.AddTag<Player>(id);
                w.AddTag<BlocksMovement>(id);
                w.AddTag<BlocksPush>(id);
                w.Set(id, new Holding(null));
                w.Set(id, new Facing(GridDir.Down));
                break;

            case EntityRecipe.WallStrong:
                w.AddTag<BlocksMovement>(id);
                w.AddTag<BlocksPush>(id);
                w.AddTag<WallStrong>(id);
                w.AddTag<Static>(id);
                break;

            case EntityRecipe.WallWeak:
                w.AddTag<BlocksMovement>(id);
                w.AddTag<WallWeak>(id);
                w.AddTag<Static>(id);
                break;

            case EntityRecipe.WaterCell:
                w.AddTag<WaterCell>(id);
                w.AddTag<Static>(id);
                break;

            case EntityRecipe.Box:
                w.AddTag<BlocksMovement>(id);
                w.AddTag<BlocksPush>(id);
                w.AddTag<Pushable>(id);
                w.AddTag<Liftable>(id);
                w.AddTag<Openable>(id);
                w.AddTag<Smashable>(id);
                break;

            case EntityRecipe.HeavyBox:
                w.AddTag<BlocksMovement>(id);
                w.AddTag<BlocksPush>(id);
                w.AddTag<Pushable>(id);
                w.AddTag<Heavy>(id);
                w.AddTag<Sinkable>(id);
                break;

            case EntityRecipe.KeyBlue:   SpawnKey(w, id, KeyColor.Blue); break;
            case EntityRecipe.KeyRed:    SpawnKey(w, id, KeyColor.Red); break;
            case EntityRecipe.KeyYellow: SpawnKey(w, id, KeyColor.Yellow); break;
            case EntityRecipe.KeyGreen:  SpawnKey(w, id, KeyColor.Green); break;

            case EntityRecipe.DoorBlue:   SpawnDoor(w, id, KeyColor.Blue); break;
            case EntityRecipe.DoorRed:    SpawnDoor(w, id, KeyColor.Red); break;
            case EntityRecipe.DoorYellow: SpawnDoor(w, id, KeyColor.Yellow); break;
            case EntityRecipe.DoorGreen:  SpawnDoor(w, id, KeyColor.Green); break;

            case EntityRecipe.SecretDoor:
                w.AddTag<Door>(id);
                w.AddTag<BlocksMovement>(id);
                w.AddTag<BlocksPush>(id);
                w.Set(id, new SignalTarget(SecretDoorGroup, SignalBehavior.OpenWhilePressed));
                break;

            case EntityRecipe.PressurePlateBlue:   SpawnPlate(w, id, PlateGroupBlue, requiresHeavy: false); break;
            case EntityRecipe.PressurePlateRed:    SpawnPlate(w, id, PlateGroupRed, requiresHeavy: false); break;
            case EntityRecipe.PressurePlateYellow: SpawnPlate(w, id, PlateGroupYellow, requiresHeavy: false); break;
            case EntityRecipe.PressurePlateGreen:  SpawnPlate(w, id, PlateGroupGreen, requiresHeavy: false); break;
            case EntityRecipe.PressurePlateHeavy:  SpawnPlate(w, id, PlateGroupHeavy, requiresHeavy: true); break;

            case EntityRecipe.StairsUp:
            case EntityRecipe.StairsDown:
                w.Set(id, new LevelTransition("", ""));
                w.AddTag<Walkable>(id);
                break;

            case EntityRecipe.Apple:  SpawnFruit(w, id, GlobalCounter.ApplesEaten); break;
            case EntityRecipe.Pear:   SpawnFruit(w, id, GlobalCounter.PearsEaten); break;
            case EntityRecipe.Cheese: SpawnFruit(w, id, GlobalCounter.CheeseEaten); break;
            case EntityRecipe.Coin:
                w.AddTag<Pushable>(id);
                w.AddTag<Liftable>(id);
                w.AddTag<BlocksMovement>(id);
                w.AddTag<BlocksPush>(id);
                w.Set(id, new Edible(GlobalCounter.Coins));
                break;

            case EntityRecipe.Duck:
                w.Set(id, new Named("duck"));
                w.AddTag<Pushable>(id);
                w.AddTag<Liftable>(id);
                w.AddTag<BlocksMovement>(id);
                w.AddTag<BlocksPush>(id);
                break;

            // Not implemented in this slice:
            case EntityRecipe.Hammer:
            case EntityRecipe.Bong:
            case EntityRecipe.Cauldron:
            case EntityRecipe.Book:
            case EntityRecipe.Ghost:
            default:
                throw new System.NotImplementedException(
                    $"Recipe {recipe} is not yet implemented; see the rewrite design spec.");
        }
    }

    private const int PlateGroupBlue = 1;
    private const int PlateGroupRed = 2;
    private const int PlateGroupYellow = 3;
    private const int PlateGroupGreen = 4;
    private const int PlateGroupHeavy = 5;
    private const int SecretDoorGroup = 100;

    private static void SpawnKey(World w, int id, KeyColor color)
    {
        w.Set(id, new KeyOf(color));
        w.AddTag<Pushable>(id);
        w.AddTag<Liftable>(id);
        w.AddTag<BlocksMovement>(id);
        w.AddTag<BlocksPush>(id);
    }

    private static void SpawnDoor(World w, int id, KeyColor color)
    {
        w.AddTag<Door>(id);
        w.AddTag<BlocksMovement>(id);
        w.AddTag<BlocksPush>(id);
        w.Set(id, new LockedBy(color));
    }

    private static void SpawnPlate(World w, int id, int group, bool requiresHeavy)
    {
        w.Set(id, new PressurePlate(group, requiresHeavy));
        w.AddTag<Walkable>(id);
    }

    private static void SpawnFruit(World w, int id, GlobalCounter counter)
    {
        w.AddTag<Pushable>(id);
        w.AddTag<Liftable>(id);
        w.AddTag<BlocksMovement>(id);
        w.AddTag<BlocksPush>(id);
        w.Set(id, new Edible(counter));
    }
}
  • Step 4: Run the test
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~RecipeFactoryEnvTests"

Expected: 8 passed, 0 failed.

  • Step 5: Run the full suite
dotnet test muscleman-godot4/Muscleman.sln

Expected: all green.

  • Step 6: Commit
git add muscleman-godot4/src/Muscleman.Sim/Recipes/RecipeFactory.cs \
        muscleman-godot4/src/Muscleman.Sim.Tests/RecipeFactoryEnvTests.cs
git commit -m "$(cat <<'EOF'
sim: extend RecipeFactory — doors, keys, plates, stairs, fruit, duck

Adds the entity recipes used by env-phase scenarios. Plate groups are
small ints; secret-door group is 100 to avoid collision. Stairs spawn
with empty target ids — level loader will populate them later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 11: Fruit eaten when held + pushed into a hard blocker

Files:

  • Modify: muscleman-godot4/src/Muscleman.Sim/Systems/PlayerActionSystem.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/FruitTests.cs

  • Step 1: Write the failing test

Write muscleman-godot4/src/Muscleman.Sim.Tests/FruitTests.cs:

using FluentAssertions;
using Muscleman.Core.Enums;
using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Muscleman.Core.Input;
using Muscleman.Sim;
using Muscleman.Sim.Components;
using Muscleman.Sim.Recipes;
using Xunit;

namespace Muscleman.Sim.Tests;

public class FruitTests
{
    [Fact]
    public void Held_apple_pushed_into_wall_is_eaten_and_player_advances()
    {
        var w = new World();
        var player = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(5, 5));
        var apple = RecipeFactory.Spawn(w, EntityRecipe.Apple, new GridPos(6, 5));
        var wall = RecipeFactory.Spawn(w, EntityRecipe.WallStrong, new GridPos(7, 5));

        // Grab the apple first (player must face it).
        w.Set(player, new Facing(GridDir.Right));
        StepRunner.Step(w, player, new Input.Grab());
        w.Get<Holding>(player).EntityId.Should().Be(apple);

        var batch = StepRunner.Step(w, player, new Input.Move(GridDir.Right));

        w.IsAlive(apple).Should().BeFalse();
        w.Get<Position>(player).Pos.Should().Be(new GridPos(6, 5));
        w.Counters[GlobalCounter.ApplesEaten].Should().Be(1);
        batch.OfType<ItemCollected>().Should().ContainSingle()
            .Which.Counter.Should().Be(GlobalCounter.ApplesEaten);
        batch.OfType<GrabReleased>().Should().ContainSingle();
        w.Get<Holding>(player).EntityId.Should().BeNull();
    }
}
  • Step 2: Run test to verify it fails
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~FruitTests.Held"

Expected: fails — current ResolveMove simply drops the apple on blocked push.

  • Step 3: Extend ResolveMove’s held-entity branch

In muscleman-godot4/src/Muscleman.Sim/Systems/PlayerActionSystem.cs, modify the held-entity branch to handle Edible consumption. Replace the block starting if (heldId is int held) through its return; (currently around lines 49–80) with:

        if (heldId is int held)
        {
            var heldFromPos = w.Get<Position>(held).Pos;
            var heldToPos = heldFromPos + dir.ToOffset();

            if (IsBlockedIgnoring(w, toPos, ignoreId: held))
                return; // Player blocked.

            var heldDestPushable = w.Index.PushableAt(heldToPos);
            if (heldDestPushable is int && heldDestPushable.Value != playerId)
            {
                if (!PushSystem.TryPush(w, heldToPos, dir, events))
                {
                    if (TryConsumeHeldFruit(w, playerId, held, fromPos, toPos, events))
                        return;
                    w.Set(playerId, new Holding(null));
                    events.Add(new GrabReleased(playerId, held));
                    return;
                }
            }
            else if (IsBlockedIgnoring(w, heldToPos, ignoreId: playerId))
            {
                if (TryConsumeHeldFruit(w, playerId, held, fromPos, toPos, events))
                    return;
                w.Set(playerId, new Holding(null));
                events.Add(new GrabReleased(playerId, held));
                return;
            }

            w.Move(held, heldToPos);
            events.Add(new Moved(held, heldFromPos, heldToPos, MoveCause.Player));

            w.Move(playerId, toPos);
            events.Add(new Moved(playerId, fromPos, toPos, MoveCause.Player));
            return;
        }

Then add the helper method to the same class (e.g., right before IsBlockedIgnoring):

    private static bool TryConsumeHeldFruit(World w, int playerId, int held, GridPos playerFromPos, GridPos playerToPos, EventBatch events)
    {
        if (!w.Has<Edible>(held)) return false;

        var counter = w.Get<Edible>(held).Counter;
        w.Counters.TryGetValue(counter, out var current);
        w.Counters[counter] = current + 1;

        events.Add(new ItemCollected(held, counter));
        w.Set(playerId, new Holding(null));
        events.Add(new GrabReleased(playerId, held));
        w.AddTag<PendingDestroy>(held);

        // Player advances into the held entity's old cell (co-occupied with the
        // PendingDestroy fruit until end-of-tick cleanup).
        w.Move(playerId, playerToPos);
        events.Add(new Moved(playerId, playerFromPos, playerToPos, MoveCause.Player));
        return true;
    }
  • Step 4: Run the test
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~FruitTests.Held"

Expected: 1 passed.

  • Step 5: Full suite
dotnet test muscleman-godot4/Muscleman.sln

Expected: all green.

  • Step 6: Commit
git add muscleman-godot4/src/Muscleman.Sim/Systems/PlayerActionSystem.cs \
        muscleman-godot4/src/Muscleman.Sim.Tests/FruitTests.cs
git commit -m "$(cat <<'EOF'
sim: eat held fruit on failed push, advance player into freed cell

Co-occupies the cell with the PendingDestroy fruit for one tick;
cleanup removes the fruit at end of tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 12: Fruit eaten when pushed (unheld) into a hard blocker

Files:

  • Modify: muscleman-godot4/src/Muscleman.Sim/Systems/PlayerActionSystem.cs
  • Modify: muscleman-godot4/src/Muscleman.Sim.Tests/FruitTests.cs

  • Step 1: Add the failing test (append to FruitTests.cs)

Add inside the FruitTests class:

    [Fact]
    public void Unheld_apple_pushed_into_wall_is_eaten_and_player_advances()
    {
        var w = new World();
        var player = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(5, 5));
        var apple = RecipeFactory.Spawn(w, EntityRecipe.Apple, new GridPos(6, 5));
        var wall = RecipeFactory.Spawn(w, EntityRecipe.WallStrong, new GridPos(7, 5));

        var batch = StepRunner.Step(w, player, new Input.Move(GridDir.Right));

        w.IsAlive(apple).Should().BeFalse();
        w.Get<Position>(player).Pos.Should().Be(new GridPos(6, 5));
        w.Counters[GlobalCounter.ApplesEaten].Should().Be(1);
        batch.OfType<ItemCollected>().Should().ContainSingle();
    }
  • Step 2: Run the test to verify it fails
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~FruitTests.Unheld"

Expected: fails — the unheld push currently just rejects without consuming the fruit.

  • Step 3: Extend ResolveMove’s unheld branch

In muscleman-godot4/src/Muscleman.Sim/Systems/PlayerActionSystem.cs, locate the unheld branch — the block starting // No held entity — fall through to the original walk/push path. Replace through the function end with:

        // No held entity — fall through to the original walk/push path.
        if (w.Index.PushableAt(toPos) is int frontId)
        {
            if (!PushSystem.TryPush(w, toPos, dir, events))
            {
                if (w.Has<Edible>(frontId))
                {
                    var counter = w.Get<Edible>(frontId).Counter;
                    w.Counters.TryGetValue(counter, out var current);
                    w.Counters[counter] = current + 1;
                    events.Add(new ItemCollected(frontId, counter));
                    w.AddTag<PendingDestroy>(frontId);

                    w.Move(playerId, toPos);
                    events.Add(new Moved(playerId, fromPos, toPos, MoveCause.Player));
                }
                return; // push failed — player either advanced via fruit-eat, or stays put
            }
            // Push succeeded — fall through, but the destination cell may still
            // hold a co-located BlocksMovement (e.g. a WallWeak the box was on).
        }

        if (w.Index.HasTagAt<BlocksMovement>(toPos))
            return; // blocked by a non-pushable still in the destination cell

        w.Move(playerId, toPos);
        events.Add(new Moved(playerId, fromPos, toPos, MoveCause.Player));
    }
  • Step 4: Run the test
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~FruitTests"

Expected: both fruit tests pass.

  • Step 5: Full suite
dotnet test muscleman-godot4/Muscleman.sln

Expected: all green.

  • Step 6: Commit
git add muscleman-godot4/src/Muscleman.Sim/Systems/PlayerActionSystem.cs \
        muscleman-godot4/src/Muscleman.Sim.Tests/FruitTests.cs
git commit -m "$(cat <<'EOF'
sim: eat unheld fruit on failed push, advance player into freed cell

Mirrors the held-fruit path: counter incremented, ItemCollected
emitted, fruit queued for PendingDestroy, player advances.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 13: Key consumed by matching locked door

Files:

  • Modify: muscleman-godot4/src/Muscleman.Sim/Systems/PushSystem.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/KeyIntoDoorTests.cs

  • Step 1: Write the failing test

Write muscleman-godot4/src/Muscleman.Sim.Tests/KeyIntoDoorTests.cs:

using FluentAssertions;
using Muscleman.Core.Enums;
using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Muscleman.Core.Input;
using Muscleman.Sim;
using Muscleman.Sim.Components;
using Muscleman.Sim.Recipes;
using Xunit;

namespace Muscleman.Sim.Tests;

public class KeyIntoDoorTests
{
    [Fact]
    public void Push_blue_key_into_blue_door_opens_door_and_consumes_key()
    {
        var w = new World();
        var player = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(5, 5));
        var key = RecipeFactory.Spawn(w, EntityRecipe.KeyBlue, new GridPos(6, 5));
        var door = RecipeFactory.Spawn(w, EntityRecipe.DoorBlue, new GridPos(7, 5));

        var batch = StepRunner.Step(w, player, new Input.Move(GridDir.Right));

        w.IsAlive(key).Should().BeFalse();
        w.HasTag<DoorOpen>(door).Should().BeTrue();
        w.HasTag<BlocksMovement>(door).Should().BeFalse();
        batch.OfType<KeyConsumed>().Should().ContainSingle()
            .Which.DoorId.Should().Be(door);
        batch.OfType<DoorOpened>().Should().ContainSingle();
    }

    [Fact]
    public void Push_blue_key_into_red_door_fails_and_key_stays()
    {
        var w = new World();
        var player = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(5, 5));
        var key = RecipeFactory.Spawn(w, EntityRecipe.KeyBlue, new GridPos(6, 5));
        var door = RecipeFactory.Spawn(w, EntityRecipe.DoorRed, new GridPos(7, 5));

        var batch = StepRunner.Step(w, player, new Input.Move(GridDir.Right));

        w.IsAlive(key).Should().BeTrue();
        w.Get<Position>(key).Pos.Should().Be(new GridPos(6, 5));
        w.HasTag<DoorOpen>(door).Should().BeFalse();
        batch.OfType<KeyConsumed>().Should().BeEmpty();
    }
}
  • Step 2: Run test to verify it fails
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~KeyIntoDoorTests"

Expected: the matching-color case fails because PushSystem.TryPush rejects on the door’s BlocksPush.

  • Step 3: Extend PushSystem.TryPush

Replace the body of muscleman-godot4/src/Muscleman.Sim/Systems/PushSystem.cs with:

using Muscleman.Core.Enums;
using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Muscleman.Sim.Components;
using Muscleman.Sim.Systems; // DoorSystem.OpenDoor

namespace Muscleman.Sim.Systems;

public static class PushSystem
{
    /// <summary>
    /// Attempts to push the chain of pushables starting at <paramref name="boxPos"/>
    /// in <paramref name="dir"/>. The chain is the maximal contiguous run of
    /// pushable entities beginning at boxPos. The push succeeds atomically if
    /// the cell beyond the last pushable is free of BlocksPush. BlocksMovement
    /// alone (e.g. WallWeak) is a soft terminus — the chain can land on it.
    ///
    /// Special case: if the hard terminus is a Door + LockedBy(color) and the
    /// frontmost pushable is KeyOf(color') with matching color, the terminus
    /// is reinterpreted as soft for this resolution. The key lands on the
    /// door's cell, the door opens, the key is queued for destruction.
    ///
    /// Returns true on success; false on rejection.
    /// </summary>
    public static bool TryPush(World w, GridPos boxPos, GridDir dir, EventBatch events)
    {
        var firstPushable = w.Index.PushableAt(boxPos);
        if (firstPushable is null) return false;

        var line = new List<int>();
        var positions = new List<GridPos>();
        var cursor = boxPos;
        var offset = dir.ToOffset();

        while (true)
        {
            var hereId = w.Index.PushableAt(cursor);
            if (hereId is null) break;
            line.Add(hereId.Value);
            positions.Add(cursor);
            cursor += offset;
        }

        var keyIntoDoorId = TryFindMatchingLockedDoor(w, line[0], cursor);
        if (w.Index.HasTagAt<BlocksPush>(cursor) && keyIntoDoorId is null) return false;

        for (int i = line.Count - 1; i >= 0; i--)
        {
            var id = line[i];
            var from = positions[i];
            var to = from + offset;
            w.Move(id, to);
            events.Add(new Moved(id, from, to, MoveCause.Push));
        }

        if (keyIntoDoorId is int doorId)
        {
            DoorSystem.OpenDoor(w, doorId, events);
            events.Add(new KeyConsumed(line[0], doorId));
            w.AddTag<PendingDestroy>(line[0]);
        }

        return true;
    }

    /// <summary>
    /// Returns the id of a Door + LockedBy(color) at <paramref name="cell"/>
    /// whose color matches the frontmost pushable's KeyOf component. Returns
    /// null if there is no such door or the frontmost pushable isn't a key.
    /// </summary>
    private static int? TryFindMatchingLockedDoor(World w, int frontmostPushableId, GridPos cell)
    {
        if (!w.Has<KeyOf>(frontmostPushableId)) return null;
        var keyColor = w.Get<KeyOf>(frontmostPushableId).Color;

        foreach (var id in w.Index.AllAt(cell))
        {
            if (!w.HasTag<Door>(id)) continue;
            if (!w.Has<LockedBy>(id)) continue;
            if (w.Get<LockedBy>(id).Color == keyColor) return id;
        }
        return null;
    }
}
  • Step 4: Run the tests
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~KeyIntoDoorTests"

Expected: 2 passed.

  • Step 5: Full suite
dotnet test muscleman-godot4/Muscleman.sln

Expected: all green.

  • Step 6: Commit
git add muscleman-godot4/src/Muscleman.Sim/Systems/PushSystem.cs \
        muscleman-godot4/src/Muscleman.Sim.Tests/KeyIntoDoorTests.cs
git commit -m "$(cat <<'EOF'
sim: PushSystem — matching key into locked door opens door, consumes key

Reinterprets a Door+LockedBy hard terminus as soft when the line's
front is a matching-color KeyOf. Reuses DoorSystem.OpenDoor for the
state transition; key queued via PendingDestroy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 14: Parity scenarios in Scenarios.All

Files:

  • Modify: muscleman-godot4/src/Muscleman.Sim.TestFixtures/Scenarios.cs
  • Modify: muscleman-godot4/engine/tests/ParityTests.cs (if it lists scenarios by name)

  • Step 1: Check whether the engine ParityTests.cs lists scenarios by name

Run:

grep -n "WalkOneStepNorth\|PushOneBoxEast\|GrabThenDrop" muscleman-godot4/engine/tests/ParityTests.cs

If the file references each scenario by name (separate [TestCase] methods), you’ll need to add a new method per scenario added below. If the file iterates Scenarios.All dynamically, no engine-side change is needed.

The Phase-1 plan locked in “one [TestCase] per scenario” — assume per-method references unless the grep proves otherwise.

  • Step 2: Append the new scenarios to Scenarios.cs

In muscleman-godot4/src/Muscleman.Sim.TestFixtures/Scenarios.cs, before the All declaration, add the scenarios. Each scenario must produce a deterministic input sequence and an event-batch expectation. The full list (paste below the existing GrabThenDrop definition):

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

    public static readonly Scenario LightOnLightPlateOpensDoor = new(
        Name: "LightOnLightPlateOpensDoor",
        Seed: w =>
        {
            var player = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(5, 5));
            var plate = RecipeFactory.Spawn(w, EntityRecipe.PressurePlateBlue, new GridPos(6, 5));
            var door = w.SpawnEmpty(new GridPos(8, 5));
            w.AddTag<Door>(door);
            w.AddTag<BlocksMovement>(door);
            w.AddTag<BlocksPush>(door);
            w.Set(door, new SignalTarget(group: 1, behavior: SignalBehavior.OpenWhilePressed));
            return new ScenarioContext(w, player, new Dictionary<string, int>
            {
                ["plate"] = plate,
                ["door"] = door,
            });
        },
        Inputs: new List<Input> { new Input.Move(GridDir.Right) },
        ExpectedEvents: ctx => new List<SimEvent>
        {
            new Moved(ctx.PlayerId, new GridPos(5, 5), new GridPos(6, 5), MoveCause.Player),
            new PlatePressed(ctx.Named["plate"]),
            new DoorOpened(ctx.Named["door"]),
        });

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

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

    public static readonly Scenario StairsEmitTransition = new(
        Name: "StairsEmitTransition",
        Seed: w =>
        {
            var player = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(5, 5));
            var stairs = w.SpawnEmpty(new GridPos(6, 5));
            w.Set(stairs, new LevelTransition("level2", "spawnA"));
            w.AddTag<Walkable>(stairs);
            return new ScenarioContext(w, player, new Dictionary<string, int>
            {
                ["stairs"] = stairs,
            });
        },
        Inputs: new List<Input> { new Input.Move(GridDir.Right) },
        ExpectedEvents: ctx => new List<SimEvent>
        {
            new Moved(ctx.PlayerId, new GridPos(5, 5), new GridPos(6, 5), MoveCause.Player),
            new LevelTransitionEvent(ctx.Named["stairs"], "level2", "spawnA"),
        });

Add the necessary using for Muscleman.Sim.Components at the top of the file:

using Muscleman.Sim.Components;

Update the All list to include the new scenarios:

    public static readonly IReadOnlyList<Scenario> All = new List<Scenario>
    {
        WalkOneStepNorth,
        PushOneBoxEast,
        GrabThenDrop,
        HeavyOntoWaterSinks,
        LightOnLightPlateOpensDoor,
        FruitEatenWhenPushedIntoWall,
        KeyConsumedByMatchingDoor,
        StairsEmitTransition,
    };
  • Step 3: Run the parity tests
dotnet test muscleman-godot4/Muscleman.sln --filter "FullyQualifiedName~ParityTests"

Expected: 8 passed (3 existing + 5 new), 0 failed. If a new scenario fails because its expected event order is wrong, inspect the actual event sequence and fix the ExpectedEvents builder — the system order (per StepRunner.Step) is the source of truth, not the spec’s prose. Player-phase events come first; env-phase emits in system call order (Sinking, Plate, Trigger, Signal-derived Door, Transition); cleanup emits nothing.

  • Step 4: Update the engine-side ParityTests if needed

If Step 1 showed per-scenario [TestCase] methods, append one method per new scenario in muscleman-godot4/engine/tests/ParityTests.cs matching the existing pattern (e.g., a RunScenarioByName("HeavyOntoWaterSinks") invocation). Otherwise skip.

  • Step 5: Run the engine lane

From the repo root:

cd muscleman-godot4 && make test-engine

Expected: all parity tests pass under Godot headless.

  • Step 6: Run both lanes via the top-level makefile target
cd muscleman-godot4 && make test

Expected: both test-sim and test-engine green.

  • Step 7: Commit
git add muscleman-godot4/src/Muscleman.Sim.TestFixtures/Scenarios.cs \
        muscleman-godot4/engine/tests/ParityTests.cs 2>/dev/null || \
git add muscleman-godot4/src/Muscleman.Sim.TestFixtures/Scenarios.cs
git commit -m "$(cat <<'EOF'
sim-tests: add env-phase + adjacent player-phase parity scenarios

Five new scenarios in Scenarios.All: HeavyOntoWaterSinks,
LightOnLightPlateOpensDoor, FruitEatenWhenPushedIntoWall,
KeyConsumedByMatchingDoor, StairsEmitTransition. Both xUnit and
GdUnit4 lanes pass them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Definition of done

  • All 14 tasks committed in order.
  • make test is green under muscleman-godot4/ — both xUnit and GdUnit4 lanes.
  • The sim covers (and unit-tests cover): sinking, plates, secret triggers, signal aggregation, doors (3 behaviors + key-open helper), level transitions, cleanup, fruit eating (held + unheld push), key into matching door.
  • The parity scenario list grew from 3 to 8.
  • No Recipe ... not yet implemented exceptions for: keys (4 colors), doors (4 colors + secret), pressure plates (4 colored + heavy), stairs (up/down), fruits (Apple, Pear, Cheese, Coin), duck.
  • Existing tests untouched and green.

Known follow-ups (out of scope)

  • Recipes still throwing NotImplementedException: Hammer, Bong, Cauldron, Book, Ghost. These belong with the smash-and-spawn-contents path (a follow-up plan).
  • OnLevelTransition survives across env-phase ticks; the outer level loader (Phase 5/6) will need to clear it when re-spawning the player at a destination spawn point.
  • PressurePlate initialization at world load (seeding Pressed tag from initial state without emitting PlatePressed) — needed for save-load Phase 5; see spec §8 risk 2.
  • Combinable(with, result) keys → green-key spawn pipeline. The swing-transforms plan recognized the pattern; this slice does not spawn the combined key.