Sim Swing Transforms Implementation Plan

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

Goal: Add the three swing-transform rules — hammer break, nutcrack, and key combine — to the sim. End state: a player swinging a Hammer into a wall opens every non-Heavy box in the line; a normal box squeezed between two HeavyBoxes pops open on a blocked swing; a blue + yellow key squeezed between two HeavyBoxes combines into a green key. All headless.

Architecture: Builds on sim-grab-and-swing-slide tag. Phase 3 fills in the slots Phase 2 reserved: SwingOp.Open and SwingOp.Combine get applier implementations; ResolveSwing’s hard-terminus branch grows from “always Block” into a pattern-matcher that detects hammer presence, H_X_H (nutcrack), and H_K_K_H (combine) — emitting Open and Combine ops alongside Block. All three transform branches live behind the same IsSoftTerminus check, so the slide branch is unaffected.

Tech Stack: .NET 9, C# 12+, Friflo Engine ECS 3.6.0, xUnit, FluentAssertions. Same as Phases 1 and 2.

Reference spec: docs/superpowers/specs/2026-05-03-godot4-ecs-rewrite-design.md — sections 3.2 (component catalog: Contents, Combinable, Hammer), 3.3 (recipes for Hammer + colored keys), 4.4 (resolve), and 4.5 (swing rule matrix — the hammer/squeeze/combine rows that Phase 2 left for now).

Out of scope for this plan (deferred to later plans):

  • Weighted-random Contents (Phase 3 implements single-recipe Contents only; weighted random needs deterministic RNG, which is its own concern)
  • Door & key interactions beyond combining (Phase 4: walking a key into a matching door)
  • Pressure plate signals
  • Environmental phase (sinking, etc.)
  • Save/load
  • Godot adapter

File Structure

Files this plan creates or modifies:

muscleman-godot4/
└── src/
    ├── Muscleman.Core/
    │   └── Events/Events.cs                        # MODIFIED: add Combined event
    ├── Muscleman.Sim/
    │   ├── Components/SwingTransforms.cs           # NEW: Contents, Combinable, Kind
    │   ├── Recipes/RecipeFactory.cs                # MODIFIED: add Hammer + KeyBlue/Yellow/Green recipes; tag Kind on every spawn
    │   └── Swing/
    │       ├── ApplySwingOps.cs                    # MODIFIED: implement Open + Combine
    │       └── ResolveSwing.cs                     # MODIFIED: add hammer + nutcrack + combine branches
    └── Muscleman.Sim.Tests/
        ├── SwingTransformsRecipeTests.cs           # NEW
        ├── ApplyOpenTests.cs                       # NEW
        ├── ApplyCombineTests.cs                    # NEW
        ├── ResolveSwingHammerTests.cs              # NEW
        ├── ResolveSwingNutcrackTests.cs            # NEW
        ├── ResolveSwingCombineTests.cs             # NEW
        ├── ResolveSwingMultiPatternTests.cs        # NEW
        └── SwingTransformsIntegrationTests.cs      # NEW

The SwingTransforms.cs file groups three closely-related components: Contents (what spawns when a box opens), Combinable (what merges with what), and Kind (what recipe an entity was spawned from — used to discriminate combine partners). Keeping them together signals their shared role.


Task 0: Components and recipes for transforms

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim/Components/SwingTransforms.cs
  • Modify: muscleman-godot4/src/Muscleman.Core/Events/Events.cs (add Combined event)
  • Modify: muscleman-godot4/src/Muscleman.Sim/Recipes/RecipeFactory.cs (add Hammer + Key recipes + tag every spawn with Kind)
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/SwingTransformsRecipeTests.cs

This task lays the data foundation. New components: Contents (a single EntityRecipe payload — what spawns on Open), Combinable (With + Result recipes), Kind (Recipe field — the recipe an entity was spawned from). The Hammer tag already exists (Phase 1 Task 4); Phase 1 just didn’t have a recipe for it yet.

  • Step 1: Add the SwingTransforms components

Path: muscleman-godot4/src/Muscleman.Sim/Components/SwingTransforms.cs

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

namespace Muscleman.Sim.Components;

/// <summary>What spawns when an entity is opened via a swing. Single recipe for now;
/// weighted random is deferred to a future plan that pairs with a deterministic RNG.</summary>
public struct Contents : IComponent
{
    public EntityRecipe Recipe;
    public Contents(EntityRecipe recipe) { Recipe = recipe; }
}

/// <summary>Indicates this entity can merge with another via a swing combine.
/// <see cref="With"/> names the partner recipe; <see cref="Result"/> names what
/// the pair produces. Both partners must carry a Combinable that mirrors each other
/// (blue's With=Yellow Result=Green; yellow's With=Blue Result=Green).</summary>
public struct Combinable : IComponent
{
    public EntityRecipe With;
    public EntityRecipe Result;
    public Combinable(EntityRecipe with, EntityRecipe result) { With = with; Result = result; }
}

/// <summary>The recipe this entity was spawned from. Used by the combine resolver to
/// match Combinable.With against the partner's actual recipe identity. Set automatically
/// by <see cref="Recipes.RecipeFactory"/> on every spawn.</summary>
public struct Kind : IComponent
{
    public EntityRecipe Recipe;
    public Kind(EntityRecipe r) { Recipe = r; }
}
  • Step 2: Add the Combined event

Modify muscleman-godot4/src/Muscleman.Core/Events/Events.cs. Find the existing event records and add this one after KeyConsumed:

public sealed record Combined(int EntityA, int EntityB, int Result) : SimEvent;

(Place between KeyConsumed and DoorOpened to keep the lock/door-related events grouped.)

  • Step 3: Write the failing recipe tests

Path: muscleman-godot4/src/Muscleman.Sim.Tests/SwingTransformsRecipeTests.cs

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

namespace Muscleman.Sim.Tests;

public class SwingTransformsRecipeTests
{
    [Fact]
    public void Hammer_recipe_has_Hammer_tag_and_is_Liftable_and_Pushable()
    {
        var w = new World();
        var id = RecipeFactory.Spawn(w, EntityRecipe.Hammer, new GridPos(1, 1));

        w.HasTag<Hammer>(id).Should().BeTrue();
        w.HasTag<Liftable>(id).Should().BeTrue();
        w.HasTag<Pushable>(id).Should().BeTrue();
        w.HasTag<BlocksMovement>(id).Should().BeTrue();
    }

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

        w.Has<Combinable>(id).Should().BeTrue();
        var c = w.Get<Combinable>(id);
        c.With.Should().Be(EntityRecipe.KeyYellow);
        c.Result.Should().Be(EntityRecipe.KeyGreen);
    }

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

        var c = w.Get<Combinable>(id);
        c.With.Should().Be(EntityRecipe.KeyBlue);
        c.Result.Should().Be(EntityRecipe.KeyGreen);
    }

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

        w.Has<Combinable>(id).Should().BeFalse();
        w.HasTag<Liftable>(id).Should().BeTrue();
    }

    [Fact]
    public void Every_spawn_attaches_a_Kind_component_naming_the_recipe()
    {
        var w = new World();
        var box = RecipeFactory.Spawn(w, EntityRecipe.Box, new GridPos(0, 0));
        var heavy = RecipeFactory.Spawn(w, EntityRecipe.HeavyBox, new GridPos(1, 0));
        var keyBlue = RecipeFactory.Spawn(w, EntityRecipe.KeyBlue, new GridPos(2, 0));

        w.Get<Kind>(box).Recipe.Should().Be(EntityRecipe.Box);
        w.Get<Kind>(heavy).Recipe.Should().Be(EntityRecipe.HeavyBox);
        w.Get<Kind>(keyBlue).Recipe.Should().Be(EntityRecipe.KeyBlue);
    }

    [Fact]
    public void Box_recipe_can_optionally_carry_Contents_set_post_spawn()
    {
        // RecipeFactory doesn't set Contents — that's per-instance authoring data set by
        // the level loader (or by tests). Verify the component slot works.
        var w = new World();
        var box = RecipeFactory.Spawn(w, EntityRecipe.Box, new GridPos(0, 0));
        w.Set(box, new Contents(EntityRecipe.Coin));

        w.Has<Contents>(box).Should().BeTrue();
        w.Get<Contents>(box).Recipe.Should().Be(EntityRecipe.Coin);
    }
}
  • Step 4: Run tests; confirm failure
cd /home/henri/muscleman/muscleman-godot4
dotnet test --filter FullyQualifiedName~SwingTransformsRecipeTests

Expected: build fails (the new components compile, but Hammer, KeyBlue, KeyYellow, KeyGreen recipes throw NotImplementedException from RecipeFactory’s default branch, AND Kind is not yet attached on spawn).

  • Step 5: Update RecipeFactory

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

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. Every spawn is tagged with a <see cref="Kind"/>
    /// component naming the recipe — used downstream by combine pattern detection.
    /// </summary>
    public static int Spawn(World w, EntityRecipe recipe, GridPos pos)
    {
        var id = w.SpawnEmpty(pos);
        w.Set(id, new Kind(recipe));
        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.Hammer:
                w.AddTag<BlocksMovement>(id);
                w.AddTag<BlocksPush>(id);
                w.AddTag<Pushable>(id);
                w.AddTag<Liftable>(id);
                w.AddTag<Hammer>(id);
                break;

            case EntityRecipe.KeyBlue:
                w.AddTag<BlocksMovement>(id);
                w.AddTag<BlocksPush>(id);
                w.AddTag<Pushable>(id);
                w.AddTag<Liftable>(id);
                w.Set(id, new Combinable(EntityRecipe.KeyYellow, EntityRecipe.KeyGreen));
                break;

            case EntityRecipe.KeyYellow:
                w.AddTag<BlocksMovement>(id);
                w.AddTag<BlocksPush>(id);
                w.AddTag<Pushable>(id);
                w.AddTag<Liftable>(id);
                w.Set(id, new Combinable(EntityRecipe.KeyBlue, EntityRecipe.KeyGreen));
                break;

            case EntityRecipe.KeyGreen:
                w.AddTag<BlocksMovement>(id);
                w.AddTag<BlocksPush>(id);
                w.AddTag<Pushable>(id);
                w.AddTag<Liftable>(id);
                // No Combinable — green is a terminal product.
                break;

            // Recipes still deferred to later plans:
            default:
                throw new System.NotImplementedException(
                    $"Recipe {recipe} is not yet implemented in this plan; see the rewrite design spec.");
        }
    }
}
  • Step 6: Run tests; confirm pass
dotnet test

Expected: 6 new SwingTransformsRecipeTests pass; previous tests (84) still pass. Total: 90.

  • Step 7: Commit
cd /home/henri/muscleman
git add muscleman-godot4
git commit -m "sim: add Contents/Combinable/Kind components and Hammer+Key recipes

Lays the data foundation for swing transforms. Every recipe spawn now
attaches a Kind component naming the recipe — used by combine pattern
matching to discriminate partners. Hammer recipe makes the held-Hammer
swing detection possible; KeyBlue/Yellow are Combinable into KeyGreen.

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

Task 1: ApplySwingOps.Open implementation

Files:

  • Modify: muscleman-godot4/src/Muscleman.Sim/Swing/ApplySwingOps.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/ApplyOpenTests.cs

Open ops mean “smash this box and spawn its contents”. Behavior: read the box’s Position and Contents. If Contents is missing (an empty box), the box is destroyed silently. If Contents is present, spawn the recipe at the box’s position, destroy the box, emit Smashed(boxId, [spawnedId]). The Phase 2 NotImplementedException branch goes away.

  • Step 1: Write the failing tests

Path: muscleman-godot4/src/Muscleman.Sim.Tests/ApplyOpenTests.cs

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.Swing;
using Xunit;
using FluentAssertions;

namespace Muscleman.Sim.Tests;

public class ApplyOpenTests
{
    [Fact]
    public void Open_op_on_box_with_Contents_spawns_recipe_at_position_and_destroys_box()
    {
        var t = new TestWorld().Player(0, 0);
        var box = RecipeFactory.Spawn(t.World, EntityRecipe.Box, new GridPos(5, 5));
        t.World.Set(box, new Contents(EntityRecipe.KeyBlue));
        var events = new EventBatch();

        ApplySwingOps.Apply(t.World, new List<SwingOp> { new SwingOp.Open(box) }, events);

        // Box destroyed.
        t.World.IsAlive(box).Should().BeFalse();
        t.World.Index.AllAt(new GridPos(5, 5)).Should().NotContain(box);

        // A KeyBlue spawned at (5,5).
        var atCell = t.World.Index.AllAt(new GridPos(5, 5));
        atCell.Should().HaveCount(1);
        var spawned = atCell[0];
        t.World.Get<Kind>(spawned).Recipe.Should().Be(EntityRecipe.KeyBlue);
        t.World.Get<Position>(spawned).Pos.Should().Be(new GridPos(5, 5));

        // Smashed event references both old and new ids.
        var smashed = events.OfType<Smashed>().Should().ContainSingle().Subject;
        smashed.EntityId.Should().Be(box);
        smashed.ContentsSpawnedIds.Should().ContainSingle().Which.Should().Be(spawned);
    }

    [Fact]
    public void Open_op_on_box_without_Contents_destroys_box_silently()
    {
        var t = new TestWorld().Player(0, 0);
        var box = RecipeFactory.Spawn(t.World, EntityRecipe.Box, new GridPos(5, 5));
        // No Contents component set.
        var events = new EventBatch();

        ApplySwingOps.Apply(t.World, new List<SwingOp> { new SwingOp.Open(box) }, events);

        t.World.IsAlive(box).Should().BeFalse();
        // Smashed still fires, but with empty ContentsSpawnedIds.
        var smashed = events.OfType<Smashed>().Should().ContainSingle().Subject;
        smashed.EntityId.Should().Be(box);
        smashed.ContentsSpawnedIds.Should().BeEmpty();
    }

    [Fact]
    public void Open_op_alongside_Move_ops_applies_move_first_then_open()
    {
        // Reverse-iterating Move ops then forward-iterating non-Move ops means a Move ahead
        // of an Open in the list executes before the Open. This is the contract — verify it.
        var t = new TestWorld().Player(0, 0).Box(1, 0, "movable");
        var box = RecipeFactory.Spawn(t.World, EntityRecipe.Box, new GridPos(5, 5));
        t.World.Set(box, new Contents(EntityRecipe.KeyBlue));
        var events = new EventBatch();

        ApplySwingOps.Apply(t.World, new List<SwingOp>
        {
            new SwingOp.Move(t.Named["movable"], GridDir.Right),
            new SwingOp.Open(box),
        }, events);

        t.PosOf("movable").Should().Be(new GridPos(2, 0));
        t.World.IsAlive(box).Should().BeFalse();
    }
}
  • Step 2: Run tests; confirm failure
dotnet test --filter FullyQualifiedName~ApplyOpenTests

Expected: 3 tests fail — currently ApplySwingOps.Apply throws NotImplementedException for Open ops.

  • Step 3: Implement Open in ApplySwingOps

Replace the contents of muscleman-godot4/src/Muscleman.Sim/Swing/ApplySwingOps.cs with:

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

namespace Muscleman.Sim.Swing;

public static class ApplySwingOps
{
    /// <summary>
    /// Applies a list of swing ops to the world atomically. Move ops are applied in
    /// REVERSE list order so each destination is empty when the move executes (same
    /// discipline as PushSystem chain commit). Other op kinds apply in their natural
    /// order; Open spawns Contents and destroys the smashed entity; Combine destroys
    /// both inputs and spawns the result.
    /// </summary>
    public static void Apply(World w, IReadOnlyList<SwingOp> ops, EventBatch events)
    {
        // Move ops are emitted in line-front-to-back order by the resolver but must
        // be applied back-to-front. Pull them out, iterate in reverse.
        for (int i = ops.Count - 1; i >= 0; i--)
        {
            if (ops[i] is SwingOp.Move move)
            {
                ApplyMove(w, move, events);
            }
        }

        // Non-move ops apply in natural order (no inter-dependencies among them).
        foreach (var op in ops)
        {
            switch (op)
            {
                case SwingOp.Move:
                    // Already applied above.
                    break;
                case SwingOp.Block:
                    // Animation/sound hint only — no state change.
                    break;
                case SwingOp.Open openOp:
                    ApplyOpen(w, openOp, events);
                    break;
                case SwingOp.Combine combineOp:
                    throw new System.NotImplementedException(
                        "SwingOp.Combine is implemented in the next task of this plan.");
            }
        }
    }

    private static void ApplyMove(World w, SwingOp.Move move, EventBatch events)
    {
        var from = w.Get<Position>(move.Entity).Pos;
        var to = from + move.Dir.ToOffset();
        w.Move(move.Entity, to);
        events.Add(new Moved(move.Entity, from, to, MoveCause.Swing));
    }

    private static void ApplyOpen(World w, SwingOp.Open op, EventBatch events)
    {
        var pos = w.Get<Position>(op.Container).Pos;
        var spawnedIds = new List<int>();

        if (w.Has<Contents>(op.Container))
        {
            var contents = w.Get<Contents>(op.Container);
            // Destroy the container BEFORE spawning so the spawned entity doesn't share
            // the cell with the dying entity (briefly violating the at-most-one-Pushable
            // invariant). The two operations don't depend on each other ordering-wise
            // beyond this.
            w.Destroy(op.Container);
            var spawned = RecipeFactory.Spawn(w, contents.Recipe, pos);
            spawnedIds.Add(spawned);
        }
        else
        {
            w.Destroy(op.Container);
        }

        events.Add(new Smashed(op.Container, spawnedIds.ToArray()));
    }
}

(Note: ApplyCombine is left throwing NotImplementedException so this task doesn’t pull in combine logic — Task 2 will fill it in. The signature of ApplyOpen mirrors the cell-vacate-before-spawn discipline you’d want for any spawn-on-destroy pattern.)

  • Step 4: Run tests; confirm pass
dotnet test --filter FullyQualifiedName~ApplyOpenTests

Expected: 3 ApplyOpenTests pass; 90 total.

Wait — full suite total: 90 + 3 = 93. Run dotnet test (no filter) to verify nothing regressed.

dotnet test

Expected: 93 passing.

  • Step 5: Commit
cd /home/henri/muscleman
git add muscleman-godot4
git commit -m "sim: implement SwingOp.Open in ApplySwingOps

Open op reads Contents from the smashed entity, destroys it, and spawns
the contained recipe at the same position. Emits Smashed event with the
spawned entity ids. Empty boxes (no Contents) destroy silently with an
empty Smashed.ContentsSpawnedIds.

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

Task 2: ApplySwingOps.Combine implementation

Files:

  • Modify: muscleman-godot4/src/Muscleman.Sim/Swing/ApplySwingOps.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/ApplyCombineTests.cs

Combine ops merge two entities into a third. Behavior: read positions of A and B. Destroy both. Spawn Result recipe at A’s position. Emit Combined(a, b, resultId).

  • Step 1: Write the failing tests

Path: muscleman-godot4/src/Muscleman.Sim.Tests/ApplyCombineTests.cs

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.Swing;
using Xunit;
using FluentAssertions;

namespace Muscleman.Sim.Tests;

public class ApplyCombineTests
{
    [Fact]
    public void Combine_op_destroys_both_inputs_and_spawns_result_at_first_position()
    {
        var t = new TestWorld().Player(0, 0);
        var blue = RecipeFactory.Spawn(t.World, EntityRecipe.KeyBlue, new GridPos(3, 5));
        var yellow = RecipeFactory.Spawn(t.World, EntityRecipe.KeyYellow, new GridPos(4, 5));
        var events = new EventBatch();

        ApplySwingOps.Apply(
            t.World,
            new List<SwingOp> { new SwingOp.Combine(blue, yellow, EntityRecipe.KeyGreen) },
            events);

        t.World.IsAlive(blue).Should().BeFalse();
        t.World.IsAlive(yellow).Should().BeFalse();

        // Green key spawned at blue's position (the "A" position of the Combine op).
        var atCell = t.World.Index.AllAt(new GridPos(3, 5));
        atCell.Should().ContainSingle();
        var green = atCell[0];
        t.World.Get<Kind>(green).Recipe.Should().Be(EntityRecipe.KeyGreen);
        t.World.HasTag<Liftable>(green).Should().BeTrue();
    }

    [Fact]
    public void Combine_op_emits_Combined_event_with_input_and_result_ids()
    {
        var t = new TestWorld().Player(0, 0);
        var blue = RecipeFactory.Spawn(t.World, EntityRecipe.KeyBlue, new GridPos(3, 5));
        var yellow = RecipeFactory.Spawn(t.World, EntityRecipe.KeyYellow, new GridPos(4, 5));
        var events = new EventBatch();

        ApplySwingOps.Apply(
            t.World,
            new List<SwingOp> { new SwingOp.Combine(blue, yellow, EntityRecipe.KeyGreen) },
            events);

        var combined = events.OfType<Combined>().Should().ContainSingle().Subject;
        combined.EntityA.Should().Be(blue);
        combined.EntityB.Should().Be(yellow);
        // Result id is the spawned entity.
        var atCell = t.World.Index.AllAt(new GridPos(3, 5));
        combined.Result.Should().Be(atCell[0]);
    }

    [Fact]
    public void Combine_op_alongside_Block_op_applies_combine()
    {
        // Block is presenter-cue only; doesn't affect Combine application.
        var t = new TestWorld().Player(0, 0);
        var blue = RecipeFactory.Spawn(t.World, EntityRecipe.KeyBlue, new GridPos(3, 5));
        var yellow = RecipeFactory.Spawn(t.World, EntityRecipe.KeyYellow, new GridPos(4, 5));
        var wall = RecipeFactory.Spawn(t.World, EntityRecipe.WallStrong, new GridPos(5, 5));
        var events = new EventBatch();

        ApplySwingOps.Apply(
            t.World,
            new List<SwingOp>
            {
                new SwingOp.Combine(blue, yellow, EntityRecipe.KeyGreen),
                new SwingOp.Block(wall),
            },
            events);

        t.World.IsAlive(blue).Should().BeFalse();
        t.World.IsAlive(yellow).Should().BeFalse();
        events.OfType<Combined>().Should().ContainSingle();
    }
}
  • Step 2: Run tests; confirm failure
dotnet test --filter FullyQualifiedName~ApplyCombineTests

Expected: 3 tests fail (Combine still throws NotImplementedException from Task 1’s stub).

  • Step 3: Replace the Combine stub in ApplySwingOps

In muscleman-godot4/src/Muscleman.Sim/Swing/ApplySwingOps.cs:

Find the line:

                case SwingOp.Combine combineOp:
                    throw new System.NotImplementedException(
                        "SwingOp.Combine is implemented in the next task of this plan.");

Replace with:

                case SwingOp.Combine combineOp:
                    ApplyCombine(w, combineOp, events);
                    break;

And add this private method to the class (after ApplyOpen):

    private static void ApplyCombine(World w, SwingOp.Combine op, EventBatch events)
    {
        var posA = w.Get<Position>(op.A).Pos;
        // Destroy both originals first to free the cells.
        w.Destroy(op.A);
        w.Destroy(op.B);
        var resultId = RecipeFactory.Spawn(w, op.Result, posA);
        events.Add(new Combined(op.A, op.B, resultId));
    }
  • Step 4: Run tests; confirm pass
dotnet test

Expected: 96 passing (93 + 3 new ApplyCombineTests).

  • Step 5: Commit
cd /home/henri/muscleman
git add muscleman-godot4
git commit -m "sim: implement SwingOp.Combine in ApplySwingOps

Combine destroys both inputs (freeing their cells) and spawns the result
recipe at A's position. Emits Combined(a, b, resultId) event.

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

Task 3: ResolveSwing hammer-break branch

Files:

  • Modify: muscleman-godot4/src/Muscleman.Sim/Swing/ResolveSwing.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/ResolveSwingHammerTests.cs

When the held entity has the Hammer tag and the swing has a hard terminus, every NON-Heavy entity in the line gets opened. (Heavy entities are unbreakable — they’re the structural pieces.) The resolver still emits the Block op for the terminus.

  • Step 1: Write the failing tests

Path: muscleman-godot4/src/Muscleman.Sim.Tests/ResolveSwingHammerTests.cs

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

namespace Muscleman.Sim.Tests;

public class ResolveSwingHammerTests
{
    private static int SpawnHammer(World w) =>
        RecipeFactory.Spawn(w, EntityRecipe.Hammer, new GridPos(0, 0));

    [Fact]
    public void Hammer_held_with_hard_terminus_opens_every_non_Heavy_entity_in_line()
    {
        var t = new TestWorld().Player(0, 0).Box(1, 0, "b1").Box(2, 0, "b2").Wall(3, 0, name: "wall");
        var hammer = SpawnHammer(t.World);
        var line = new SwingLine(
            new List<int> { t.Named["b1"], t.Named["b2"] },
            Terminus: t.Named["wall"],
            Dir: GridDir.Right);

        var ops = ResolveSwing.Resolve(line, held: hammer, t.World);

        // Two Open ops (one per box) + one Block.
        ops.Should().HaveCount(3);
        ops.OfType<SwingOp.Open>().Should().HaveCount(2);
        ops.OfType<SwingOp.Open>().Select(o => o.Container)
            .Should().BeEquivalentTo(new[] { t.Named["b1"], t.Named["b2"] });
        ops.OfType<SwingOp.Block>().Should().ContainSingle()
            .Which.Blocker.Should().Be(t.Named["wall"]);
    }

    [Fact]
    public void Hammer_does_not_open_Heavy_entities()
    {
        // Heavy in line — Hammer skips it. Box still gets opened.
        var t = new TestWorld().Player(0, 0).Box(1, 0, "box").HeavyBox(2, 0, "heavy").Wall(3, 0, name: "wall");
        var hammer = SpawnHammer(t.World);
        var line = new SwingLine(
            new List<int> { t.Named["box"], t.Named["heavy"] },
            Terminus: t.Named["wall"],
            Dir: GridDir.Right);

        var ops = ResolveSwing.Resolve(line, held: hammer, t.World);

        ops.OfType<SwingOp.Open>().Should().ContainSingle()
            .Which.Container.Should().Be(t.Named["box"]);
        ops.OfType<SwingOp.Block>().Should().ContainSingle();
    }

    [Fact]
    public void Hammer_with_soft_terminus_just_slides_no_opens()
    {
        // Hammer with empty terminus → slide branch, no transforms.
        var t = new TestWorld().Player(0, 0).Box(1, 0, "box");
        var hammer = SpawnHammer(t.World);
        var line = new SwingLine(
            new List<int> { t.Named["box"] },
            Terminus: null,
            Dir: GridDir.Right);

        var ops = ResolveSwing.Resolve(line, held: hammer, t.World);

        ops.Should().AllBeOfType<SwingOp.Move>();
        ops.OfType<SwingOp.Open>().Should().BeEmpty();
    }

    [Fact]
    public void No_Hammer_held_with_hard_terminus_no_Opens()
    {
        // Without Hammer, hard terminus is just a Block (no transforms either, no patterns).
        var t = new TestWorld().Player(0, 0).Box(1, 0, "box").Wall(2, 0, name: "wall");
        var line = new SwingLine(
            new List<int> { t.Named["box"] },
            Terminus: t.Named["wall"],
            Dir: GridDir.Right);

        var ops = ResolveSwing.Resolve(line, held: null, t.World);

        ops.Should().ContainSingle().Which.Should().BeOfType<SwingOp.Block>();
    }
}
  • Step 2: Run tests; confirm failure
dotnet test --filter FullyQualifiedName~ResolveSwingHammerTests

Expected: tests 1, 2 fail (currently the hard-terminus branch returns just a Block, no Opens). Tests 3 and 4 pass (slide branch and pure-block already correct).

  • Step 3: Add the hammer-break branch to ResolveSwing

Replace the contents of muscleman-godot4/src/Muscleman.Sim/Swing/ResolveSwing.cs with:

using Muscleman.Sim.Components;

namespace Muscleman.Sim.Swing;

public static class ResolveSwing
{
    /// <summary>
    /// Pure function: from a scanned <see cref="SwingLine"/>, the player's currently held
    /// entity (or null), and the world (for component queries), produces a flat list of
    /// <see cref="SwingOp"/> atoms. The applier translates these into world mutations.
    ///
    /// Branches:
    ///   - Slide: terminus is null OR has WallWeak (soft). All entities in line emit Move ops.
    ///   - Block: terminus is hard. The line is inspected for transforms:
    ///       * Held has Hammer → every non-Heavy entity in line emits Open.
    ///       * (Nutcrack and Combine added in subsequent tasks.)
    ///     Plus a Block op naming the terminus.
    /// </summary>
    public static IReadOnlyList<SwingOp> Resolve(SwingLine line, int? held, World w)
    {
        if (IsSoftTerminus(line.Terminus, w))
        {
            // Slide branch: every entity in line moves one cell in the swing direction.
            var ops = new List<SwingOp>(line.Pushables.Count);
            foreach (var id in line.Pushables)
                ops.Add(new SwingOp.Move(id, line.Dir));
            return ops;
        }

        // Hard terminus — inspect for transforms.
        var hardBranchOps = new List<SwingOp>();

        // Hammer-break: held has Hammer → open every non-Heavy entity in line.
        if (held is int heldId && w.HasTag<Hammer>(heldId))
        {
            foreach (var id in line.Pushables)
            {
                if (!w.HasTag<Heavy>(id))
                    hardBranchOps.Add(new SwingOp.Open(id));
            }
        }

        // Block always fires when terminus is hard.
        var terminus = line.Terminus!.Value;
        hardBranchOps.Add(new SwingOp.Block(terminus));
        return hardBranchOps;
    }

    private static bool IsSoftTerminus(int? terminus, World w)
    {
        if (terminus is null) return true;
        if (w.HasTag<WallWeak>(terminus.Value)) return true;
        return false;
    }
}
  • Step 4: Run tests; confirm pass
dotnet test

Expected: 100 passing (96 + 4 new). All 7 prior ResolveSwingTests still pass.

  • Step 5: Commit
cd /home/henri/muscleman
git add muscleman-godot4
git commit -m "sim: ResolveSwing hammer-break branch

When held entity has Hammer tag and swing has hard terminus, every
non-Heavy entity in the line gets an Open op. Block still fires for the
terminus. Heavy entities are skipped (they are structural, unbreakable).

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

Task 4: ResolveSwing nutcrack pattern

Files:

  • Modify: muscleman-godot4/src/Muscleman.Sim/Swing/ResolveSwing.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/ResolveSwingNutcrackTests.cs

Detect H_X_H patterns in the line: any non-Heavy X with Heavy neighbors at i-1 and i+1 gets opened (nutcracked). Multiple nutcracks can fire in the same line. The pattern check runs INDEPENDENTLY of hammer-break — both can happen in one swing if conditions match.

  • Step 1: Write the failing tests

Path: muscleman-godot4/src/Muscleman.Sim.Tests/ResolveSwingNutcrackTests.cs

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

namespace Muscleman.Sim.Tests;

public class ResolveSwingNutcrackTests
{
    [Fact]
    public void Pattern_HeavyBoxHeavy_yields_Open_for_middle_box_plus_Block()
    {
        var t = new TestWorld().Player(0, 0)
            .HeavyBox(1, 0, "h1")
            .Box(2, 0, "box")
            .HeavyBox(3, 0, "h2")
            .Wall(4, 0, name: "wall");
        var line = new SwingLine(
            new List<int> { t.Named["h1"], t.Named["box"], t.Named["h2"] },
            Terminus: t.Named["wall"],
            Dir: GridDir.Right);

        var ops = ResolveSwing.Resolve(line, held: null, t.World);

        ops.OfType<SwingOp.Open>().Should().ContainSingle()
            .Which.Container.Should().Be(t.Named["box"]);
        ops.OfType<SwingOp.Block>().Should().ContainSingle();
    }

    [Fact]
    public void No_squeeze_pattern_just_blocks()
    {
        // [Heavy, Box] — no second Heavy, no nutcrack.
        var t = new TestWorld().Player(0, 0).HeavyBox(1, 0, "h").Box(2, 0, "box").Wall(3, 0, name: "wall");
        var line = new SwingLine(
            new List<int> { t.Named["h"], t.Named["box"] },
            Terminus: t.Named["wall"],
            Dir: GridDir.Right);

        var ops = ResolveSwing.Resolve(line, held: null, t.World);

        ops.Should().ContainSingle().Which.Should().BeOfType<SwingOp.Block>();
    }

    [Fact]
    public void HeavyHeavyHeavy_yields_no_Open_because_middle_is_Heavy()
    {
        var t = new TestWorld().Player(0, 0)
            .HeavyBox(1, 0, "h1")
            .HeavyBox(2, 0, "h2")
            .HeavyBox(3, 0, "h3")
            .Wall(4, 0, name: "wall");
        var line = new SwingLine(
            new List<int> { t.Named["h1"], t.Named["h2"], t.Named["h3"] },
            Terminus: t.Named["wall"],
            Dir: GridDir.Right);

        var ops = ResolveSwing.Resolve(line, held: null, t.World);

        ops.OfType<SwingOp.Open>().Should().BeEmpty();
        ops.OfType<SwingOp.Block>().Should().ContainSingle();
    }

    [Fact]
    public void Two_nutcracks_in_one_line_both_fire()
    {
        // [H, X, H, Y, H] — both X and Y are nutcracked.
        var t = new TestWorld().Player(0, 0)
            .HeavyBox(1, 0, "h1")
            .Box(2, 0, "x")
            .HeavyBox(3, 0, "h2")
            .Box(4, 0, "y")
            .HeavyBox(5, 0, "h3")
            .Wall(6, 0, name: "wall");
        var line = new SwingLine(
            new List<int>
            {
                t.Named["h1"], t.Named["x"], t.Named["h2"], t.Named["y"], t.Named["h3"],
            },
            Terminus: t.Named["wall"],
            Dir: GridDir.Right);

        var ops = ResolveSwing.Resolve(line, held: null, t.World);

        ops.OfType<SwingOp.Open>().Should().HaveCount(2);
        ops.OfType<SwingOp.Open>().Select(o => o.Container)
            .Should().BeEquivalentTo(new[] { t.Named["x"], t.Named["y"] });
    }
}
  • Step 2: Run tests; confirm failure
dotnet test --filter FullyQualifiedName~ResolveSwingNutcrackTests

Expected: tests 1 and 4 fail; 2 and 3 pass.

  • Step 3: Add the nutcrack branch to ResolveSwing

In muscleman-godot4/src/Muscleman.Sim/Swing/ResolveSwing.cs, the hard-terminus branch needs the nutcrack scan added. Replace the entire Resolve method body with:

    public static IReadOnlyList<SwingOp> Resolve(SwingLine line, int? held, World w)
    {
        if (IsSoftTerminus(line.Terminus, w))
        {
            var ops = new List<SwingOp>(line.Pushables.Count);
            foreach (var id in line.Pushables)
                ops.Add(new SwingOp.Move(id, line.Dir));
            return ops;
        }

        // Hard terminus — inspect for transforms. Open ops are deduped by entity id
        // because hammer-break and nutcrack can both target the same entity.
        var openIds = new HashSet<int>();

        // Hammer-break: held has Hammer → open every non-Heavy entity in line.
        if (held is int heldId && w.HasTag<Hammer>(heldId))
        {
            foreach (var id in line.Pushables)
            {
                if (!w.HasTag<Heavy>(id))
                    openIds.Add(id);
            }
        }

        // Nutcrack: H_X_H pattern — non-Heavy X squeezed between Heavy neighbors.
        for (int i = 1; i < line.Pushables.Count - 1; i++)
        {
            var prev = line.Pushables[i - 1];
            var mid = line.Pushables[i];
            var next = line.Pushables[i + 1];
            if (w.HasTag<Heavy>(prev) && w.HasTag<Heavy>(next) && !w.HasTag<Heavy>(mid))
                openIds.Add(mid);
        }

        var hardBranchOps = new List<SwingOp>(openIds.Count + 1);
        foreach (var id in openIds)
            hardBranchOps.Add(new SwingOp.Open(id));

        var terminus = line.Terminus!.Value;
        hardBranchOps.Add(new SwingOp.Block(terminus));
        return hardBranchOps;
    }

The HashSet<int> deduplicates Open ops automatically — Task 6’s tests will exercise the dedup explicitly.

  • Step 4: Run tests; confirm pass
dotnet test

Expected: 104 passing (100 + 4 new).

  • Step 5: Commit
cd /home/henri/muscleman
git add muscleman-godot4
git commit -m "sim: ResolveSwing nutcrack pattern

Detects H_X_H in the line — non-Heavy X squeezed between Heavy neighbors
gets an Open op. Multiple nutcracks fire simultaneously. Open ops dedup
via HashSet so future overlap with hammer-break produces one op per box.

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

Task 5: ResolveSwing combine pattern

Files:

  • Modify: muscleman-godot4/src/Muscleman.Sim/Swing/ResolveSwing.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/ResolveSwingCombineTests.cs

Detect H_K_K_H patterns: two Combinable entities squeezed between two Heavy entities, where the inner pair is a valid combine. A “valid combine” requires both entities to have Combinable, both to have Kind, and the With of each to match the partner’s Kind.Recipe. When matched, emit Combine(k1, k2, result).

  • Step 1: Write the failing tests

Path: muscleman-godot4/src/Muscleman.Sim.Tests/ResolveSwingCombineTests.cs

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

namespace Muscleman.Sim.Tests;

public class ResolveSwingCombineTests
{
    private static (TestWorld t, int h1, int kBlue, int kYellow, int h2, int wall)
        SetupBlueYellowCombine()
    {
        var t = new TestWorld().Player(0, 0);
        var h1 = RecipeFactory.Spawn(t.World, EntityRecipe.HeavyBox, new GridPos(1, 0));
        var kBlue = RecipeFactory.Spawn(t.World, EntityRecipe.KeyBlue, new GridPos(2, 0));
        var kYellow = RecipeFactory.Spawn(t.World, EntityRecipe.KeyYellow, new GridPos(3, 0));
        var h2 = RecipeFactory.Spawn(t.World, EntityRecipe.HeavyBox, new GridPos(4, 0));
        var wall = RecipeFactory.Spawn(t.World, EntityRecipe.WallStrong, new GridPos(5, 0));
        return (t, h1, kBlue, kYellow, h2, wall);
    }

    [Fact]
    public void Pattern_Heavy_Blue_Yellow_Heavy_yields_Combine_op_for_KeyGreen()
    {
        var (t, h1, kBlue, kYellow, h2, wall) = SetupBlueYellowCombine();
        var line = new SwingLine(
            new List<int> { h1, kBlue, kYellow, h2 },
            Terminus: wall,
            Dir: GridDir.Right);

        var ops = ResolveSwing.Resolve(line, held: null, t.World);

        var combine = ops.OfType<SwingOp.Combine>().Should().ContainSingle().Subject;
        combine.A.Should().Be(kBlue);
        combine.B.Should().Be(kYellow);
        combine.Result.Should().Be(EntityRecipe.KeyGreen);
        ops.OfType<SwingOp.Block>().Should().ContainSingle();
    }

    [Fact]
    public void Pattern_with_two_blue_keys_does_not_combine()
    {
        // Two blue keys aren't a valid pair (blue's With=KeyYellow, partner would need Kind=Yellow).
        var t = new TestWorld().Player(0, 0);
        var h1 = RecipeFactory.Spawn(t.World, EntityRecipe.HeavyBox, new GridPos(1, 0));
        var k1 = RecipeFactory.Spawn(t.World, EntityRecipe.KeyBlue, new GridPos(2, 0));
        var k2 = RecipeFactory.Spawn(t.World, EntityRecipe.KeyBlue, new GridPos(3, 0));
        var h2 = RecipeFactory.Spawn(t.World, EntityRecipe.HeavyBox, new GridPos(4, 0));
        var wall = RecipeFactory.Spawn(t.World, EntityRecipe.WallStrong, new GridPos(5, 0));

        var line = new SwingLine(
            new List<int> { h1, k1, k2, h2 },
            Terminus: wall,
            Dir: GridDir.Right);

        var ops = ResolveSwing.Resolve(line, held: null, t.World);

        ops.OfType<SwingOp.Combine>().Should().BeEmpty();
    }

    [Fact]
    public void Pattern_HeavyBoxBoxHeavy_does_not_combine_because_Box_has_no_Combinable()
    {
        var t = new TestWorld().Player(0, 0)
            .HeavyBox(1, 0, "h1")
            .Box(2, 0, "b1")
            .Box(3, 0, "b2")
            .HeavyBox(4, 0, "h2")
            .Wall(5, 0, name: "wall");
        var line = new SwingLine(
            new List<int> { t.Named["h1"], t.Named["b1"], t.Named["b2"], t.Named["h2"] },
            Terminus: t.Named["wall"],
            Dir: GridDir.Right);

        var ops = ResolveSwing.Resolve(line, held: null, t.World);

        ops.OfType<SwingOp.Combine>().Should().BeEmpty();
    }

    [Fact]
    public void Combine_pattern_requires_Heavy_anchors_on_both_outer_positions()
    {
        // [Box, Blue, Yellow, Heavy] — left anchor is not Heavy → no combine.
        var t = new TestWorld().Player(0, 0).Box(1, 0, "b");
        var kBlue = RecipeFactory.Spawn(t.World, EntityRecipe.KeyBlue, new GridPos(2, 0));
        var kYellow = RecipeFactory.Spawn(t.World, EntityRecipe.KeyYellow, new GridPos(3, 0));
        var h2 = RecipeFactory.Spawn(t.World, EntityRecipe.HeavyBox, new GridPos(4, 0));
        var wall = RecipeFactory.Spawn(t.World, EntityRecipe.WallStrong, new GridPos(5, 0));
        var line = new SwingLine(
            new List<int> { t.Named["b"], kBlue, kYellow, h2 },
            Terminus: wall,
            Dir: GridDir.Right);

        var ops = ResolveSwing.Resolve(line, held: null, t.World);

        ops.OfType<SwingOp.Combine>().Should().BeEmpty();
    }
}
  • Step 2: Run tests; confirm failure
dotnet test --filter FullyQualifiedName~ResolveSwingCombineTests

Expected: test 1 fails (no combine fires); 2, 3, 4 pass (no Combine ops emitted, which is correct).

  • Step 3: Add the combine branch to ResolveSwing

In muscleman-godot4/src/Muscleman.Sim/Swing/ResolveSwing.cs, add the combine scan after the nutcrack scan in the hard-terminus branch. Replace the entire Resolve method:

    public static IReadOnlyList<SwingOp> Resolve(SwingLine line, int? held, World w)
    {
        if (IsSoftTerminus(line.Terminus, w))
        {
            var ops = new List<SwingOp>(line.Pushables.Count);
            foreach (var id in line.Pushables)
                ops.Add(new SwingOp.Move(id, line.Dir));
            return ops;
        }

        // Hard terminus — inspect for transforms. Open ops are deduped by entity id
        // because hammer-break and nutcrack can both target the same entity.
        var openIds = new HashSet<int>();
        var combines = new List<SwingOp.Combine>();

        // Hammer-break: held has Hammer → open every non-Heavy entity in line.
        if (held is int heldId && w.HasTag<Hammer>(heldId))
        {
            foreach (var id in line.Pushables)
            {
                if (!w.HasTag<Heavy>(id))
                    openIds.Add(id);
            }
        }

        // Nutcrack: H_X_H pattern — non-Heavy X squeezed between Heavy neighbors.
        for (int i = 1; i < line.Pushables.Count - 1; i++)
        {
            var prev = line.Pushables[i - 1];
            var mid = line.Pushables[i];
            var next = line.Pushables[i + 1];
            if (w.HasTag<Heavy>(prev) && w.HasTag<Heavy>(next) && !w.HasTag<Heavy>(mid))
                openIds.Add(mid);
        }

        // Combine: H_K_K_H pattern — two Combinable entities squeezed between Heavy anchors.
        for (int i = 1; i < line.Pushables.Count - 2; i++)
        {
            var prev = line.Pushables[i - 1];
            var a = line.Pushables[i];
            var b = line.Pushables[i + 1];
            var next = line.Pushables[i + 2];

            if (!w.HasTag<Heavy>(prev) || !w.HasTag<Heavy>(next)) continue;
            if (!CanCombine(w, a, b, out var result)) continue;

            combines.Add(new SwingOp.Combine(a, b, result));
        }

        var hardBranchOps = new List<SwingOp>(openIds.Count + combines.Count + 1);
        foreach (var id in openIds)
            hardBranchOps.Add(new SwingOp.Open(id));
        hardBranchOps.AddRange(combines);

        var terminus = line.Terminus!.Value;
        hardBranchOps.Add(new SwingOp.Block(terminus));
        return hardBranchOps;
    }

    private static bool CanCombine(World w, int a, int b, out Muscleman.Core.Enums.EntityRecipe result)
    {
        result = default;
        if (!w.Has<Combinable>(a) || !w.Has<Combinable>(b)) return false;
        if (!w.Has<Kind>(a) || !w.Has<Kind>(b)) return false;

        var ca = w.Get<Combinable>(a);
        var cb = w.Get<Combinable>(b);
        var ka = w.Get<Kind>(a);
        var kb = w.Get<Kind>(b);

        // Each side's With must match the other side's Kind, and the Results must agree.
        if (ca.With != kb.Recipe) return false;
        if (cb.With != ka.Recipe) return false;
        if (ca.Result != cb.Result) return false;

        result = ca.Result;
        return true;
    }
  • Step 4: Run tests; confirm pass
dotnet test

Expected: 108 passing (104 + 4 new). All previous tests still pass.

  • Step 5: Commit
cd /home/henri/muscleman
git add muscleman-godot4
git commit -m "sim: ResolveSwing combine pattern

Detects H_K_K_H — two Combinable entities squeezed between Heavy
anchors with matching With/Kind/Result fields. Emits SwingOp.Combine
naming the result recipe. Multiple combines fire simultaneously.

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

Task 6: Multiple patterns and dedup

Files:

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

Verify that the resolver correctly handles lines where multiple transform patterns coexist (hammer + nutcrack on same box, multiple nutcracks alongside a combine, etc.) and dedupes Open ops by entity id. The implementation already supports this via the HashSet<int> for open ids — this task is the test-level confirmation.

  • Step 1: Write the tests

Path: muscleman-godot4/src/Muscleman.Sim.Tests/ResolveSwingMultiPatternTests.cs

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

namespace Muscleman.Sim.Tests;

public class ResolveSwingMultiPatternTests
{
    [Fact]
    public void Hammer_and_nutcrack_targeting_same_box_emit_one_Open_op_only()
    {
        // Line: [Heavy, Box, Heavy] with Hammer held + hard terminus.
        // Both hammer-break (open all non-Heavy) and nutcrack (open the squeezed) target the box.
        // Dedup → exactly one Open op.
        var t = new TestWorld().Player(0, 0)
            .HeavyBox(1, 0, "h1")
            .Box(2, 0, "box")
            .HeavyBox(3, 0, "h2")
            .Wall(4, 0, name: "wall");
        var hammer = RecipeFactory.Spawn(t.World, EntityRecipe.Hammer, new GridPos(0, 5));
        var line = new SwingLine(
            new List<int> { t.Named["h1"], t.Named["box"], t.Named["h2"] },
            Terminus: t.Named["wall"],
            Dir: GridDir.Right);

        var ops = ResolveSwing.Resolve(line, held: hammer, t.World);

        ops.OfType<SwingOp.Open>().Should().ContainSingle()
            .Which.Container.Should().Be(t.Named["box"]);
    }

    [Fact]
    public void Two_nutcracks_and_one_combine_can_coexist()
    {
        // [H, X, H, KeyBlue, KeyYellow, H, Y, H] — nutcracks on X, Y; combine on the keys.
        var t = new TestWorld().Player(0, 0);
        var h1 = RecipeFactory.Spawn(t.World, EntityRecipe.HeavyBox, new GridPos(1, 0));
        var x = RecipeFactory.Spawn(t.World, EntityRecipe.Box, new GridPos(2, 0));
        var h2 = RecipeFactory.Spawn(t.World, EntityRecipe.HeavyBox, new GridPos(3, 0));
        var kBlue = RecipeFactory.Spawn(t.World, EntityRecipe.KeyBlue, new GridPos(4, 0));
        var kYellow = RecipeFactory.Spawn(t.World, EntityRecipe.KeyYellow, new GridPos(5, 0));
        var h3 = RecipeFactory.Spawn(t.World, EntityRecipe.HeavyBox, new GridPos(6, 0));
        var y = RecipeFactory.Spawn(t.World, EntityRecipe.Box, new GridPos(7, 0));
        var h4 = RecipeFactory.Spawn(t.World, EntityRecipe.HeavyBox, new GridPos(8, 0));
        var wall = RecipeFactory.Spawn(t.World, EntityRecipe.WallStrong, new GridPos(9, 0));

        var line = new SwingLine(
            new List<int> { h1, x, h2, kBlue, kYellow, h3, y, h4 },
            Terminus: wall,
            Dir: GridDir.Right);

        var ops = ResolveSwing.Resolve(line, held: null, t.World);

        // x is between h1 (line[0]) and h2 (line[2]) → nutcrack.
        // y is between h3 (line[5]) and h4 (line[7]) → nutcrack.
        // kBlue/kYellow between h2 (line[2]) and h3 (line[5]) → combine. (4 elements between heavies.)
        ops.OfType<SwingOp.Open>().Select(o => o.Container)
            .Should().BeEquivalentTo(new[] { x, y });
        ops.OfType<SwingOp.Combine>().Should().ContainSingle()
            .Which.Result.Should().Be(EntityRecipe.KeyGreen);
        ops.OfType<SwingOp.Block>().Should().ContainSingle();
    }
}
  • Step 2: Run tests; confirm pass

The implementation from Tasks 3-5 already handles dedup and multi-pattern via the HashSet<int>. These tests should pass without further code changes.

dotnet test --filter FullyQualifiedName~ResolveSwingMultiPatternTests

Expected: both tests pass.

If test 2 fails, the combine pattern detection might need its anchor positions verified — prev is at i-1 and next is at i+2. For i=3 (KeyBlue), prev is line[2] = h2, next is line[5] = h3. That’s correct. Trace through if needed.

  • Step 3: Run the full suite
dotnet test

Expected: 110 passing (108 + 2 new).

  • Step 4: Commit
cd /home/henri/muscleman
git add muscleman-godot4
git commit -m "sim: tests for multi-pattern swings and Open op dedup

Verifies hammer-break and nutcrack targeting the same box dedupe to
a single Open op, and that two nutcracks + one combine coexist
in a single line resolution.

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

Task 7: End-to-end SwingSystem integration

Files:

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

Smoke tests confirming the whole pipeline works end-to-end through SwingSystem.Resolve (which the player input flows through). Set up a world, push an Input.SwingRight/Left, verify the world ends up in the expected state. No new sim code — this exercises the integration.

  • Step 1: Write the tests

Path: muscleman-godot4/src/Muscleman.Sim.Tests/SwingTransformsIntegrationTests.cs

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;
using FluentAssertions;

namespace Muscleman.Sim.Tests;

public class SwingTransformsIntegrationTests
{
    [Fact]
    public void Swing_with_Hammer_into_wall_with_box_in_the_arc_opens_the_box()
    {
        // Player at (5,5), holds Hammer at (6,5). SwingRight wants destination (5,6).
        // Box at (5,6); Wall at (4,6) (hard terminus from the swing-line scan in exit dir Left).
        // Hammer-break: opens the box.
        var t = new TestWorld().Player(5, 5);
        var hammer = RecipeFactory.Spawn(t.World, EntityRecipe.Hammer, new GridPos(6, 5));
        t.World.Set(t.PlayerId, new Facing(GridDir.Right));
        StepRunner.Step(t.World, t.PlayerId, new Input.Grab());
        var box = RecipeFactory.Spawn(t.World, EntityRecipe.Box, new GridPos(5, 6));
        t.World.Set(box, new Contents(EntityRecipe.KeyBlue));
        var wall = RecipeFactory.Spawn(t.World, EntityRecipe.WallStrong, new GridPos(4, 6));

        var batch = StepRunner.Step(t.World, t.PlayerId, new Input.SwingRight());

        t.World.IsAlive(box).Should().BeFalse();
        // Box was at (5,6); KeyBlue spawned there.
        var atCell = t.World.Index.AllAt(new GridPos(5, 6));
        atCell.Should().HaveCount(1);
        t.World.Get<Kind>(atCell[0]).Recipe.Should().Be(EntityRecipe.KeyBlue);
        batch.OfType<Smashed>().Should().ContainSingle();
    }

    [Fact]
    public void Swing_into_HeavyBoxBoxHeavy_pattern_with_hard_terminus_nutcracks_the_middle_box()
    {
        // Player at (5,5), held Box at (6,5). SwingRight destination (5,6).
        // Place [HeavyBox, Box, HeavyBox] on (5,6),(4,6),(3,6) (exit direction is Left from (5,6)).
        // Wall at (2,6) — hard terminus.
        var t = new TestWorld().Player(5, 5);
        var heldBox = RecipeFactory.Spawn(t.World, EntityRecipe.Box, new GridPos(6, 5));
        t.World.Set(t.PlayerId, new Facing(GridDir.Right));
        StepRunner.Step(t.World, t.PlayerId, new Input.Grab());
        var h1 = RecipeFactory.Spawn(t.World, EntityRecipe.HeavyBox, new GridPos(5, 6));
        var middle = RecipeFactory.Spawn(t.World, EntityRecipe.Box, new GridPos(4, 6));
        t.World.Set(middle, new Contents(EntityRecipe.KeyBlue));
        var h2 = RecipeFactory.Spawn(t.World, EntityRecipe.HeavyBox, new GridPos(3, 6));
        var wall = RecipeFactory.Spawn(t.World, EntityRecipe.WallStrong, new GridPos(2, 6));

        var batch = StepRunner.Step(t.World, t.PlayerId, new Input.SwingRight());

        // Middle box was nutcracked.
        t.World.IsAlive(middle).Should().BeFalse();
        var atMiddle = t.World.Index.AllAt(new GridPos(4, 6));
        atMiddle.Should().HaveCount(1);
        t.World.Get<Kind>(atMiddle[0]).Recipe.Should().Be(EntityRecipe.KeyBlue);
        // Heavies and held box are unchanged in position.
        t.World.Get<Position>(h1).Pos.Should().Be(new GridPos(5, 6));
        t.World.Get<Position>(h2).Pos.Should().Be(new GridPos(3, 6));
        t.World.Get<Position>(heldBox).Pos.Should().Be(new GridPos(6, 5));
    }

    [Fact]
    public void Swing_into_HeavyBlueYellowHeavy_pattern_combines_into_KeyGreen()
    {
        var t = new TestWorld().Player(5, 5);
        var heldBox = RecipeFactory.Spawn(t.World, EntityRecipe.Box, new GridPos(6, 5));
        t.World.Set(t.PlayerId, new Facing(GridDir.Right));
        StepRunner.Step(t.World, t.PlayerId, new Input.Grab());
        var h1 = RecipeFactory.Spawn(t.World, EntityRecipe.HeavyBox, new GridPos(5, 6));
        var blue = RecipeFactory.Spawn(t.World, EntityRecipe.KeyBlue, new GridPos(4, 6));
        var yellow = RecipeFactory.Spawn(t.World, EntityRecipe.KeyYellow, new GridPos(3, 6));
        var h2 = RecipeFactory.Spawn(t.World, EntityRecipe.HeavyBox, new GridPos(2, 6));
        var wall = RecipeFactory.Spawn(t.World, EntityRecipe.WallStrong, new GridPos(1, 6));

        var batch = StepRunner.Step(t.World, t.PlayerId, new Input.SwingRight());

        t.World.IsAlive(blue).Should().BeFalse();
        t.World.IsAlive(yellow).Should().BeFalse();
        // Green key spawned at blue's old position (the "A" position of the Combine op).
        var atCell = t.World.Index.AllAt(new GridPos(4, 6));
        atCell.Should().ContainSingle();
        t.World.Get<Kind>(atCell[0]).Recipe.Should().Be(EntityRecipe.KeyGreen);
        batch.OfType<Combined>().Should().ContainSingle();
    }
}
  • Step 2: Run tests; confirm pass
dotnet test

Expected: 113 passing (110 + 3 new). If a test fails, trace through the geometry — the swing’s exit direction is RotateRight(rotated) for SwingRight, where rotated = RotateRight(toGrabbed). For player(5,5), held(6,5), SwingRight: rotated=Down, exitDir=Left. So the line scan from (5,6) goes Left through (5,6), (4,6), (3,6), …

  • Step 3: Commit
cd /home/henri/muscleman
git add muscleman-godot4
git commit -m "sim: end-to-end integration tests for swing transforms

Verifies hammer-break, nutcrack, and key-combine fire correctly through
SwingSystem.Resolve when triggered by Input.SwingRight from a player
holding the right item with the right line setup. No new sim code —
this exercises the integration of the full pipeline.

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

Task 8: Final integration + tag

Files: none new.

  • Step 1: Run full suite
cd /home/henri/muscleman/muscleman-godot4
dotnet test

Expected: 113 passing, 0 failing.

  • Step 2: Verify the sim is still Godot-free
cd /home/henri/muscleman
grep -r "Godot" muscleman-godot4/src/Muscleman.Core muscleman-godot4/src/Muscleman.Sim 2>&1 | grep -v "/bin/" | grep -v "/obj/"

Expected: empty output.

  • Step 3: Verify ApplySwingOps no longer throws NotImplementedException
grep -n "NotImplementedException" muscleman-godot4/src/Muscleman.Sim/Swing/ApplySwingOps.cs

Expected: empty output. (If you see any results outside intentional contexts, the Open/Combine implementations missed something.)

RecipeFactory.cs will still throw NotImplementedException for not-yet-implemented recipes (Doors, PressurePlates, Stairs, etc.). That’s fine — those recipes are deferred to later plans.

  • Step 4: Tag the milestone
cd /home/henri/muscleman
git tag -a sim-swing-transforms -m "Sim swing transforms: hammer break, nutcrack, key combine"
  • Step 5: Verify the tag
git tag -l sim-swing-transforms
git log --oneline sim-swing-transforms -1

Expected: tag listed, points to the latest commit on the branch.


Definition of Done

  • dotnet test passes 113 tests.
  • The sim-swing-transforms tag points to a commit with all of the above tasks merged.
  • The sim is still genuinely Godot-free.
  • ApplySwingOps.Apply no longer throws NotImplementedException for any SwingOp variant.
  • Hammer recipe + KeyBlue/Yellow/Green recipes are spawnable.
  • The Kind component is present on every recipe-spawned entity.

What’s Next

The next plan in the sequence is Phase 4: environmental phase — adds sinking, pressure plate occupancy, signal-driven doors, secret triggers, level transitions, and the full World.Step orchestration with phase ordering (Player → Actor → Environmental → Cleanup → Emit). Approximate scope: 8–10 tasks.