Sim phase 4 — Environmental phase and rule-set completion
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
TestWorldwhere a fluent builder helps; otherwise build entities directly viaWorld.SpawnEmpty,World.AddTag<T>,World.Set(id, component). - New systems are
public static classwith aTick(World w, EventBatch events)method (some takeint playerIdtoo — noted per system). - After every implementation step, run
dotnet test muscleman-godot4/Muscleman.slnfrom 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 existingPushSystemis 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
SignalBehaviorenum
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(andCounters) toWorld
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.cslists 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
ParityTestsif 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 testis green undermuscleman-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 implementedexceptions 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). OnLevelTransitionsurvives 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.PressurePlateinitialization at world load (seedingPressedtag from initial state without emittingPlatePressed) — 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.