Sim Grab/Drop & Swing Slide 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 grab/drop, walk-while-holding, push-while-holding, swing scan, swing slide branch, and the Swung event to the sim. End state: player can grab a Box, walk while carrying it (chain-pushing if the held entity bumps into a line), and swing it 90° around themselves — sliding any contiguous line of boxes that’s in the swing’s exit path. All headless.

Architecture: Builds on the sim-walk-push milestone tag. Same three-project layout. New systems wired into the existing PlayerActionSystem.Resolve switch (Input.Grab/Drop and Input.SwingLeft/SwingRight cases gain bodies; Input.Move’s branch is extended for held-while-walking). New file SwingSystem.cs holds the swing pipeline (scan → resolve → apply); the swing logic is a flat list of SwingOp atoms per spec §4.4.

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

Reference spec: docs/superpowers/specs/2026-05-03-godot4-ecs-rewrite-design.md — sections 4.4 (swing resolution: scan/resolve/apply), 4.5 (swing rule matrix — only the slide and block rows are in scope this plan), and 4.7 (invariants).

Out of scope for this plan (deferred to Phase 3):

  • Hammer break (Open ops in the resolve when held entity has Hammer tag)
  • Nutcracking (squeeze pattern detection: H_X_H)
  • Key combining (Combine ops, H_K_K_H pattern)
  • Environmental phase (sinking, plates, signals, doors)
  • Save/load
  • Godot adapter / presenter

File Structure

Files this plan creates or modifies:

muscleman-godot4/
└── src/
    ├── Muscleman.Sim/
    │   ├── Systems/PlayerActionSystem.cs      # MODIFIED: Grab/Drop/Swing cases gain bodies; ResolveMove handles Holding
    │   ├── Systems/PushSystem.cs              # UNCHANGED: chain push reused by held-walk
    │   ├── Systems/SwingSystem.cs             # NEW: orchestrates scan + resolve + apply
    │   ├── Swing/SwingLine.cs                 # NEW: scan result struct
    │   ├── Swing/SwingOp.cs                   # NEW: discriminated union of swing ops
    │   ├── Swing/ScanSwingLine.cs             # NEW: pure scan function
    │   ├── Swing/ResolveSwing.cs              # NEW: pure resolver (slide + block branches)
    │   └── Swing/ApplySwingOps.cs             # NEW: applier — switch over op kinds
    └── Muscleman.Sim.Tests/
        ├── GrabTests.cs                       # NEW
        ├── HoldingWalkTests.cs                # NEW
        ├── HoldingPushTests.cs                # NEW
        ├── ScanSwingLineTests.cs              # NEW
        ├── ResolveSwingTests.cs               # NEW
        └── SwingSystemTests.cs                # NEW

Each Swing/* file has one clear responsibility — scan, resolve, apply, types — making the swing pipeline trivial to test in isolation. The SwingSystem.cs orchestrator is the only file that knows about all five.


Task 0: Grab and Drop (no carrying yet)

Files:

  • Modify: muscleman-godot4/src/Muscleman.Sim/Systems/PlayerActionSystem.cs (add ResolveGrab, ResolveDrop; route Input.Grab/Drop to them)
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/GrabTests.cs

Player presses Grab → if facing a Liftable in the adjacent cell, attach it via Holding(id), emit GrabAttached. Player presses Drop → if currently holding, detach, emit GrabReleased. If Grab is pressed while already holding, the existing held entity is dropped first (toggle behavior matches GD3).

  • Step 1: Write the failing tests

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

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

namespace Muscleman.Sim.Tests;

public class GrabTests
{
    [Fact]
    public void Grab_attaches_adjacent_Liftable_in_facing_direction()
    {
        var t = new TestWorld().Player(5, 5).Box(6, 5, "box");
        // Make the player face right by stepping right then back (or just set Facing directly).
        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));
        // Player is now at (6, 5) holding nothing — actually, push moved the box. Re-set up:
        var t2 = new TestWorld().Player(5, 5).Box(6, 5, "box");
        // Manually set facing to Right without walking (to keep the box where it is).
        t2.World.Set(t2.PlayerId, new Facing(GridDir.Right));

        var batch = StepRunner.Step(t2.World, t2.PlayerId, new Input.Grab());

        t2.World.Get<Holding>(t2.PlayerId).EntityId.Should().Be(t2.Named["box"]);
        batch.OfType<GrabAttached>().Should().ContainSingle()
            .Which.EntityId.Should().Be(t2.Named["box"]);
    }

    [Fact]
    public void Grab_with_no_adjacent_Liftable_is_a_no_op()
    {
        var t = new TestWorld().Player(5, 5);
        t.World.Set(t.PlayerId, new Facing(GridDir.Right));

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

        t.World.Get<Holding>(t.PlayerId).EntityId.Should().BeNull();
        batch.OfType<GrabAttached>().Should().BeEmpty();
    }

    [Fact]
    public void Grab_does_not_attach_a_Wall_because_Wall_is_not_Liftable()
    {
        var t = new TestWorld().Player(5, 5).Wall(6, 5);
        t.World.Set(t.PlayerId, new Facing(GridDir.Right));

        StepRunner.Step(t.World, t.PlayerId, new Input.Grab());

        t.World.Get<Holding>(t.PlayerId).EntityId.Should().BeNull();
    }

    [Fact]
    public void Grab_does_not_attach_a_HeavyBox_because_HeavyBox_is_not_Liftable()
    {
        var t = new TestWorld().Player(5, 5).HeavyBox(6, 5, "heavy");
        t.World.Set(t.PlayerId, new Facing(GridDir.Right));

        StepRunner.Step(t.World, t.PlayerId, new Input.Grab());

        t.World.Get<Holding>(t.PlayerId).EntityId.Should().BeNull();
    }

    [Fact]
    public void Drop_detaches_the_held_entity_and_emits_GrabReleased()
    {
        var t = new TestWorld().Player(5, 5).Box(6, 5, "box");
        t.World.Set(t.PlayerId, new Facing(GridDir.Right));
        StepRunner.Step(t.World, t.PlayerId, new Input.Grab());

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

        t.World.Get<Holding>(t.PlayerId).EntityId.Should().BeNull();
        batch.OfType<GrabReleased>().Should().ContainSingle()
            .Which.EntityId.Should().Be(t.Named["box"]);
    }

    [Fact]
    public void Drop_with_nothing_held_is_a_no_op()
    {
        var t = new TestWorld().Player(5, 5);
        var batch = StepRunner.Step(t.World, t.PlayerId, new Input.Drop());

        batch.OfType<GrabReleased>().Should().BeEmpty();
    }

    [Fact]
    public void Grab_while_already_holding_drops_old_then_grabs_new()
    {
        var t = new TestWorld().Player(5, 5).Box(6, 5, "first");
        t.World.Set(t.PlayerId, new Facing(GridDir.Right));
        StepRunner.Step(t.World, t.PlayerId, new Input.Grab());
        // Now turn left and grab again — but there's nothing left. Test the toggle: a Grab with
        // an adjacent Liftable while already holding should release the old, then attempt to grab the new.
        // For simplicity here, verify the "release on re-Grab even with nothing in front" toggle.
        t.World.Set(t.PlayerId, new Facing(GridDir.Left));
        var batch = StepRunner.Step(t.World, t.PlayerId, new Input.Grab());

        t.World.Get<Holding>(t.PlayerId).EntityId.Should().BeNull();
        batch.OfType<GrabReleased>().Should().ContainSingle()
            .Which.EntityId.Should().Be(t.Named["first"]);
    }
}
  • Step 2: Run tests; confirm failure
cd /home/henri/muscleman/muscleman-godot4
dotnet test --filter FullyQualifiedName~GrabTests

Expected: 7 tests fail (Grab/Drop branches in the switch are still no-ops from Phase 1).

  • Step 3: Implement Grab/Drop in PlayerActionSystem

Modify muscleman-godot4/src/Muscleman.Sim/Systems/PlayerActionSystem.cs. Replace the entire file with:

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

namespace Muscleman.Sim.Systems;

public static class PlayerActionSystem
{
    public static void Resolve(World w, int playerId, Input input, EventBatch events)
    {
        switch (input)
        {
            case Input.Move move:
                ResolveMove(w, playerId, move.Dir, events);
                break;

            case Input.Grab:
                ResolveGrab(w, playerId, events);
                break;

            case Input.Drop:
                ResolveDrop(w, playerId, events);
                break;

            case Input.Wait:
            case Input.SwingLeft:
            case Input.SwingRight:
                // Swing handled in a later task this plan; Wait is intentionally no-op.
                break;
        }
    }

    private static void ResolveMove(World w, int playerId, GridDir dir, EventBatch events)
    {
        var fromPos = w.Get<Position>(playerId).Pos;
        var toPos = fromPos + dir.ToOffset();

        w.Set(playerId, new Facing(dir));

        if (w.Index.PushableAt(toPos) is int)
        {
            if (!PushSystem.TryPush(w, toPos, dir, events))
                return;
        }
        else if (w.Index.HasTagAt<BlocksMovement>(toPos))
        {
            return;
        }

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

    private static void ResolveGrab(World w, int playerId, EventBatch events)
    {
        // First, drop anything currently held — Grab is a toggle.
        var current = w.Get<Holding>(playerId).EntityId;
        if (current is int existing)
        {
            w.Set(playerId, new Holding(null));
            events.Add(new GrabReleased(playerId, existing));
            return;
        }

        // Look for a Liftable in the cell directly in front of the player.
        var playerPos = w.Get<Position>(playerId).Pos;
        var facing = w.Get<Facing>(playerId).Dir;
        var targetPos = playerPos + facing.ToOffset();

        foreach (var id in w.Index.AllAt(targetPos))
        {
            if (w.HasTag<Liftable>(id))
            {
                w.Set(playerId, new Holding(id));
                events.Add(new GrabAttached(playerId, id));
                return;
            }
        }
    }

    private static void ResolveDrop(World w, int playerId, EventBatch events)
    {
        var current = w.Get<Holding>(playerId).EntityId;
        if (current is int existing)
        {
            w.Set(playerId, new Holding(null));
            events.Add(new GrabReleased(playerId, existing));
        }
    }
}

The key behaviors:

  • ResolveGrab first checks if something is held — if so, release it (toggle).
  • If nothing held, scan the cell in Facing direction; first Liftable found becomes the held entity.
  • ResolveDrop is a pure release (no toggle).

AllAt returns a snapshot list — safe to iterate while we mutate other state (we don’t actually mutate the index here).

  • Step 4: Run tests; confirm pass
dotnet test

Expected: all 7 GrabTests pass; all 41 previous tests still pass. Total: 48.

  • Step 5: Commit
cd /home/henri/muscleman
git add muscleman-godot4
git commit -m "sim: add grab/drop with toggle semantics

Grab attaches the first Liftable adjacent in the player's facing direction.
Re-pressing Grab releases the held entity (toggle). Drop is an explicit
release. HeavyBox is not Liftable and cannot be grabbed.

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

Task 1: Walk while holding (basic carry)

Files:

  • Modify: muscleman-godot4/src/Muscleman.Sim/Systems/PlayerActionSystem.cs (ResolveMove extension for Holding)
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/HoldingWalkTests.cs

When the player walks while holding, the held entity also moves the same direction. Both must have clear destinations. The held entity ignores the player when checking its own destination, and vice versa. If the held entity’s destination is blocked by a wall, drop the held entity (and the player still doesn’t move). Push-while-holding (held bumps a Pushable) is the next task.

  • Step 1: Write the failing tests

Path: muscleman-godot4/src/Muscleman.Sim.Tests/HoldingWalkTests.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 Xunit;
using FluentAssertions;

namespace Muscleman.Sim.Tests;

public class HoldingWalkTests
{
    [Fact]
    public void Player_carries_held_box_when_walking_perpendicular_to_holding_direction()
    {
        // Player at (5,5), box at (6,5) (held to the right). Walk Down — box stays to the right.
        var t = new TestWorld().Player(5, 5).Box(6, 5, "box");
        t.World.Set(t.PlayerId, new Facing(GridDir.Right));
        StepRunner.Step(t.World, t.PlayerId, new Input.Grab());

        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Down));

        t.PlayerPos.Should().Be(new GridPos(5, 6));
        t.PosOf("box").Should().Be(new GridPos(6, 6));
    }

    [Fact]
    public void Player_carries_held_box_when_walking_in_held_direction()
    {
        // Player at (5,5), box at (6,5). Walk Right — box leads (lands at (7,5)), player follows to (6,5).
        var t = new TestWorld().Player(5, 5).Box(6, 5, "box");
        t.World.Set(t.PlayerId, new Facing(GridDir.Right));
        StepRunner.Step(t.World, t.PlayerId, new Input.Grab());

        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));

        t.PlayerPos.Should().Be(new GridPos(6, 5));
        t.PosOf("box").Should().Be(new GridPos(7, 5));
    }

    [Fact]
    public void Player_carries_held_box_when_walking_away_from_held_direction()
    {
        // Player at (5,5), box at (6,5). Walk Left — box trails (lands at (5,5)), player at (4,5).
        var t = new TestWorld().Player(5, 5).Box(6, 5, "box");
        t.World.Set(t.PlayerId, new Facing(GridDir.Right));
        StepRunner.Step(t.World, t.PlayerId, new Input.Grab());

        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Left));

        t.PlayerPos.Should().Be(new GridPos(4, 5));
        t.PosOf("box").Should().Be(new GridPos(5, 5));
    }

    [Fact]
    public void Player_dropped_when_carried_box_hits_a_wall()
    {
        // Player at (5,5), box at (6,5). Wall at (6,6). Walk Down — box would go to (6,6) into wall.
        // Held entity is dropped; player doesn't move (because the held box is still there).
        var t = new TestWorld().Player(5, 5).Box(6, 5, "box").Wall(6, 6);
        t.World.Set(t.PlayerId, new Facing(GridDir.Right));
        StepRunner.Step(t.World, t.PlayerId, new Input.Grab());

        var batch = StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Down));

        t.World.Get<Holding>(t.PlayerId).EntityId.Should().BeNull();
        t.PlayerPos.Should().Be(new GridPos(5, 5)); // didn't move
        t.PosOf("box").Should().Be(new GridPos(6, 5));  // didn't move either
        batch.OfType<GrabReleased>().Should().ContainSingle();
    }

    [Fact]
    public void Held_box_emits_Moved_with_Player_cause_when_carried()
    {
        var t = new TestWorld().Player(5, 5).Box(6, 5, "box");
        t.World.Set(t.PlayerId, new Facing(GridDir.Right));
        StepRunner.Step(t.World, t.PlayerId, new Input.Grab());

        var batch = StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Down));

        // Both player and held box emit a Moved with cause = Player (this is a coordinated player walk).
        batch.OfType<Moved>().Should().HaveCount(2);
        batch.OfType<Moved>().All(e => e.Cause == MoveCause.Player).Should().BeTrue();
    }
}
  • Step 2: Run tests; confirm failure
dotnet test --filter FullyQualifiedName~HoldingWalkTests

Expected: tests fail because ResolveMove doesn’t yet handle the Holding case.

  • Step 3: Extend ResolveMove to coordinate held entity movement

Replace the body of ResolveMove in muscleman-godot4/src/Muscleman.Sim/Systems/PlayerActionSystem.cs:

private static void ResolveMove(World w, int playerId, GridDir dir, EventBatch events)
{
    var fromPos = w.Get<Position>(playerId).Pos;
    var toPos = fromPos + dir.ToOffset();
    var heldId = w.Get<Holding>(playerId).EntityId;

    w.Set(playerId, new Facing(dir));

    // Holding case: coordinate player+held movement.
    if (heldId is int held)
    {
        var heldFromPos = w.Get<Position>(held).Pos;
        var heldToPos = heldFromPos + dir.ToOffset();

        // Player's destination must be clear (ignoring the held entity).
        var playerDestBlocked = IsBlockedIgnoring(w, toPos, ignoreId: held);
        if (playerDestBlocked)
            return; // Player blocked; held stays where it is.

        // Held's destination must be clear (ignoring the player).
        var heldDestBlocked = IsBlockedIgnoring(w, heldToPos, ignoreId: playerId);
        if (heldDestBlocked)
        {
            // Drop the held entity; player does not move.
            w.Set(playerId, new Holding(null));
            events.Add(new GrabReleased(playerId, held));
            return;
        }

        // Both clear: move both. Order matters — move the held FIRST if its destination
        // overlaps the player's current cell (e.g., walking away from held), otherwise
        // move the player first. To keep it simple, write both destinations atomically by
        // computing both positions, then moving in an order that doesn't violate the
        // SpatialIndex's at-most-one-Pushable invariant. Since neither has a Pushable
        // collision (we've already verified destinations clear), order is safe.
        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;
    }

    // No held entity — fall through to the original walk/push path.
    if (w.Index.PushableAt(toPos) is int)
    {
        if (!PushSystem.TryPush(w, toPos, dir, events))
            return;
    }
    else if (w.Index.HasTagAt<BlocksMovement>(toPos))
    {
        return;
    }

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

private static bool IsBlockedIgnoring(World w, GridPos cell, int ignoreId)
{
    foreach (var id in w.Index.AllAt(cell))
    {
        if (id == ignoreId) continue;
        if (w.HasTag<BlocksMovement>(id)) return true;
    }
    return false;
}

The IsBlockedIgnoring helper is the analog of the GD3 can_move(dir, grabbed) “ignore this entity in collision” pattern.

Note: the held-walk path doesn’t use PushSystem.TryPush for the held entity — that’s the next task. For now, blocked held → drop.

  • Step 4: Run tests; confirm pass
dotnet test

Expected: 5 new HoldingWalkTests pass; 48 previous still pass. Total: 53.

  • Step 5: Commit
cd /home/henri/muscleman
git add muscleman-godot4
git commit -m "sim: walk while holding — coordinate player+held movement

Both must have clear destinations (each ignoring the other). If held
hits a wall, the held is dropped and player stays put. Push-while-holding
(held bumps another Pushable) deferred to the next task.

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

Task 2: Push while holding (held bumps Pushable line)

Files:

  • Modify: muscleman-godot4/src/Muscleman.Sim/Systems/PlayerActionSystem.cs (extend the Holding branch)
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/HoldingPushTests.cs

When the held entity’s destination contains a Pushable, attempt a chain push from that cell. If the chain succeeds, both player and held move (held lands where the pushed line’s first box used to be). If chain fails, drop the held.

  • Step 1: Write the failing tests

Path: muscleman-godot4/src/Muscleman.Sim.Tests/HoldingPushTests.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 Xunit;
using FluentAssertions;

namespace Muscleman.Sim.Tests;

public class HoldingPushTests
{
    [Fact]
    public void Held_box_pushes_a_box_in_its_path_when_clear_behind()
    {
        // Player at (5,5), held box at (6,5). Box at (7,5) (in held's path if walking right). Walk Right.
        // Held should push the (7,5) box to (8,5), held lands at (7,5), player at (6,5).
        var t = new TestWorld().Player(5, 5).Box(6, 5, "held").Box(7, 5, "blocker");
        t.World.Set(t.PlayerId, new Facing(GridDir.Right));
        StepRunner.Step(t.World, t.PlayerId, new Input.Grab());

        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));

        t.PlayerPos.Should().Be(new GridPos(6, 5));
        t.PosOf("held").Should().Be(new GridPos(7, 5));
        t.PosOf("blocker").Should().Be(new GridPos(8, 5));
    }

    [Fact]
    public void Held_box_pushes_a_chain_when_clear_behind()
    {
        var t = new TestWorld()
            .Player(5, 5)
            .Box(6, 5, "held")
            .Box(7, 5, "b1")
            .Box(8, 5, "b2");
        t.World.Set(t.PlayerId, new Facing(GridDir.Right));
        StepRunner.Step(t.World, t.PlayerId, new Input.Grab());

        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));

        t.PlayerPos.Should().Be(new GridPos(6, 5));
        t.PosOf("held").Should().Be(new GridPos(7, 5));
        t.PosOf("b1").Should().Be(new GridPos(8, 5));
        t.PosOf("b2").Should().Be(new GridPos(9, 5));
    }

    [Fact]
    public void Held_box_drops_if_chain_push_is_blocked()
    {
        // Held box wants to push, but the chain terminates at a wall.
        var t = new TestWorld()
            .Player(5, 5)
            .Box(6, 5, "held")
            .Box(7, 5, "blocker")
            .Wall(8, 5);
        t.World.Set(t.PlayerId, new Facing(GridDir.Right));
        StepRunner.Step(t.World, t.PlayerId, new Input.Grab());

        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));

        t.World.Get<Holding>(t.PlayerId).EntityId.Should().BeNull();
        t.PlayerPos.Should().Be(new GridPos(5, 5));
        t.PosOf("held").Should().Be(new GridPos(6, 5));
        t.PosOf("blocker").Should().Be(new GridPos(7, 5));
    }

    [Fact]
    public void Held_box_does_not_drop_when_path_is_simply_clear_no_push_needed()
    {
        // Sanity-check regression: no push needed, normal carry-walk still works.
        var t = new TestWorld().Player(5, 5).Box(6, 5, "held");
        t.World.Set(t.PlayerId, new Facing(GridDir.Right));
        StepRunner.Step(t.World, t.PlayerId, new Input.Grab());

        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));

        t.World.Get<Holding>(t.PlayerId).EntityId.Should().Be(t.Named["held"]);
        t.PlayerPos.Should().Be(new GridPos(6, 5));
        t.PosOf("held").Should().Be(new GridPos(7, 5));
    }
}
  • Step 2: Run tests; confirm failure
dotnet test --filter FullyQualifiedName~HoldingPushTests

Expected: tests 1, 2, 3 fail (held drops on hitting any Pushable); test 4 passes.

  • Step 3: Extend ResolveMove’s Holding branch to attempt chain push

In muscleman-godot4/src/Muscleman.Sim/Systems/PlayerActionSystem.cs, replace the held-destination handling. The relevant block (currently if (heldDestBlocked) { drop; return; }) becomes:

        // Held's destination — three cases:
        //   (a) clear of any blocker (other than the player): proceed.
        //   (b) contains a Pushable line: try chain-push; if it works, proceed; else drop.
        //   (c) contains a non-Pushable BlocksMovement (wall, closed door): drop.
        var heldDestPushable = w.Index.PushableAt(heldToPos);
        if (heldDestPushable is int && heldDestPushable.Value != playerId)
        {
            // Attempt chain push starting at heldToPos.
            if (!PushSystem.TryPush(w, heldToPos, dir, events))
            {
                w.Set(playerId, new Holding(null));
                events.Add(new GrabReleased(playerId, held));
                return;
            }
            // Chain push succeeded; cell heldToPos is now empty.
        }
        else if (IsBlockedIgnoring(w, heldToPos, ignoreId: playerId))
        {
            w.Set(playerId, new Holding(null));
            events.Add(new GrabReleased(playerId, held));
            return;
        }

The full ResolveMove after this edit:

private static void ResolveMove(World w, int playerId, GridDir dir, EventBatch events)
{
    var fromPos = w.Get<Position>(playerId).Pos;
    var toPos = fromPos + dir.ToOffset();
    var heldId = w.Get<Holding>(playerId).EntityId;

    w.Set(playerId, new Facing(dir));

    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))
            {
                w.Set(playerId, new Holding(null));
                events.Add(new GrabReleased(playerId, held));
                return;
            }
        }
        else if (IsBlockedIgnoring(w, heldToPos, ignoreId: playerId))
        {
            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;
    }

    if (w.Index.PushableAt(toPos) is int)
    {
        if (!PushSystem.TryPush(w, toPos, dir, events))
            return;
    }
    else if (w.Index.HasTagAt<BlocksMovement>(toPos))
    {
        return;
    }

    w.Move(playerId, toPos);
    events.Add(new Moved(playerId, fromPos, toPos, MoveCause.Player));
}
  • Step 4: Run tests; confirm pass
dotnet test

Expected: 4 new HoldingPushTests pass; 53 previous still pass. Total: 57.

  • Step 5: Commit
cd /home/henri/muscleman
git add muscleman-godot4
git commit -m "sim: push-while-holding via PushSystem.TryPush

When the held entity's destination is a Pushable, attempt a chain push;
on success, player+held proceed and the line slid one tile. On failure,
held is dropped.

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

Task 3: SwingLine struct and ScanSwingLine

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim/Swing/SwingLine.cs
  • Create: muscleman-godot4/src/Muscleman.Sim/Swing/ScanSwingLine.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/ScanSwingLineTests.cs

ScanSwingLine walks the world from a starting cell in a given direction, collecting Pushables until the first non-Pushable cell. Per spec §4.4, scan stops at any cell that’s BlocksMovement-or-BlocksPush (returning that as the terminus), or at an empty cell (terminus = null). Sunk heavy boxes (no blockers, no Pushable) are transparent — scan continues past them.

  • Step 1: Write the failing tests

Path: muscleman-godot4/src/Muscleman.Sim.Tests/ScanSwingLineTests.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 ScanSwingLineTests
{
    [Fact]
    public void Scan_in_empty_direction_yields_empty_line_and_null_terminus()
    {
        var t = new TestWorld().Player(5, 5);
        var line = ScanSwingLine.Scan(t.World, new GridPos(6, 5), GridDir.Right);

        line.Pushables.Should().BeEmpty();
        line.Terminus.Should().BeNull();
        line.Dir.Should().Be(GridDir.Right);
    }

    [Fact]
    public void Scan_collects_a_single_Pushable_then_empty_cell()
    {
        var t = new TestWorld().Player(5, 5).Box(6, 5, "box");
        var line = ScanSwingLine.Scan(t.World, new GridPos(6, 5), GridDir.Right);

        line.Pushables.Should().ContainSingle().Which.Should().Be(t.Named["box"]);
        line.Terminus.Should().BeNull();
    }

    [Fact]
    public void Scan_collects_a_chain_of_Pushables()
    {
        var t = new TestWorld()
            .Player(0, 0)
            .Box(1, 0, "b1")
            .Box(2, 0, "b2")
            .Box(3, 0, "b3");
        var line = ScanSwingLine.Scan(t.World, new GridPos(1, 0), GridDir.Right);

        line.Pushables.Should().Equal(new[] { t.Named["b1"], t.Named["b2"], t.Named["b3"] });
        line.Terminus.Should().BeNull();
    }

    [Fact]
    public void Scan_terminates_at_a_strong_wall_setting_terminus()
    {
        var t = new TestWorld().Player(0, 0).Box(1, 0, "b1").Wall(2, 0, strong: true, name: "wall");
        var line = ScanSwingLine.Scan(t.World, new GridPos(1, 0), GridDir.Right);

        line.Pushables.Should().ContainSingle().Which.Should().Be(t.Named["b1"]);
        line.Terminus.Should().Be(t.Named["wall"]);
    }

    [Fact]
    public void Scan_terminates_at_a_weak_wall_setting_terminus()
    {
        // WallWeak has BlocksMovement but not BlocksPush — still terminates the scan.
        var t = new TestWorld().Player(0, 0).Box(1, 0, "b1").Wall(2, 0, strong: false, name: "weak");
        var line = ScanSwingLine.Scan(t.World, new GridPos(1, 0), GridDir.Right);

        line.Pushables.Should().ContainSingle().Which.Should().Be(t.Named["b1"]);
        line.Terminus.Should().Be(t.Named["weak"]);
    }

    [Fact]
    public void Scan_passes_through_a_sunk_heavy_box_transparently()
    {
        // Manually sink a heavy box: remove blockers/Pushable/Sinkable, add Walkable.
        var t = new TestWorld().Player(0, 0).Box(1, 0, "b1");
        var sunkId = RecipeFactory.Spawn(t.World, EntityRecipe.HeavyBox, new GridPos(2, 0));
        t.World.RemoveTag<BlocksMovement>(sunkId);
        t.World.RemoveTag<BlocksPush>(sunkId);
        t.World.RemoveTag<Pushable>(sunkId);
        t.World.RemoveTag<Sinkable>(sunkId);
        t.World.AddTag<Walkable>(sunkId);
        var box2 = t.Box(3, 0, "b2");

        var line = ScanSwingLine.Scan(t.World, new GridPos(1, 0), GridDir.Right);

        // Scan should pick up b1, skip the sunk heavy at (2,0), then pick up b2 at (3,0).
        line.Pushables.Should().Equal(new[] { t.Named["b1"], t.Named["b2"] });
        line.Terminus.Should().BeNull();
    }

    [Fact]
    public void Scan_starting_at_a_blocker_returns_empty_line_with_blocker_as_terminus()
    {
        // Caller passes a wall as the starting cell — line is empty, terminus is the wall.
        var t = new TestWorld().Player(0, 0).Wall(1, 0, name: "wall");
        var line = ScanSwingLine.Scan(t.World, new GridPos(1, 0), GridDir.Right);

        line.Pushables.Should().BeEmpty();
        line.Terminus.Should().Be(t.Named["wall"]);
    }
}
  • Step 2: Run tests; confirm failure
dotnet test --filter FullyQualifiedName~ScanSwingLineTests

Expected: build fails (SwingLine, ScanSwingLine don’t exist).

  • Step 3: Define SwingLine

Path: muscleman-godot4/src/Muscleman.Sim/Swing/SwingLine.cs

using Muscleman.Core.Grid;

namespace Muscleman.Sim.Swing;

/// <summary>
/// Result of <see cref="ScanSwingLine.Scan"/>: the contiguous run of Pushable entities
/// found in the scan direction, plus the entity at the cell where the scan stopped
/// (the terminus). Terminus is null when the scan ran off into an empty cell.
/// </summary>
public sealed record SwingLine(
    IReadOnlyList<int> Pushables,
    int? Terminus,
    GridDir Dir);
  • Step 4: Implement ScanSwingLine

Path: muscleman-godot4/src/Muscleman.Sim/Swing/ScanSwingLine.cs

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

namespace Muscleman.Sim.Swing;

public static class ScanSwingLine
{
    /// <summary>
    /// Walks <paramref name="dir"/> from <paramref name="start"/>, collecting Pushable
    /// entities. Stops at the first cell containing any BlocksMovement or BlocksPush
    /// entity that is NOT itself a Pushable extending the line. Cells with neither a
    /// blocker nor a Pushable (empty floor, sunk-heavy-box bridges, open doors) are
    /// transparent — the scan passes through them.
    /// </summary>
    public static SwingLine Scan(World w, GridPos start, GridDir dir)
    {
        var line = new List<int>();
        var cursor = start;
        var offset = dir.ToOffset();

        while (true)
        {
            var pushable = w.Index.PushableAt(cursor);
            if (pushable is int p)
            {
                line.Add(p);
                cursor += offset;
                continue;
            }

            // No Pushable here. Is something blocking?
            int? blocker = null;
            foreach (var id in w.Index.AllAt(cursor))
            {
                if (w.HasTag<BlocksMovement>(id) || w.HasTag<BlocksPush>(id))
                {
                    blocker = id;
                    break;
                }
            }

            if (blocker is int b)
                return new SwingLine(line, b, dir);

            // Nothing blocks here — the cell is empty or only contains Walkable/transparent
            // entities (sunk heavy boxes, open doors, pressure plates without Solid).
            // The line ends; terminus is null.
            return new SwingLine(line, null, dir);
        }
    }
}
  • Step 5: Run tests; confirm pass
dotnet test --filter FullyQualifiedName~ScanSwingLineTests

Expected: all 7 ScanSwingLineTests pass; total 64.

  • Step 6: Commit
cd /home/henri/muscleman
git add muscleman-godot4
git commit -m "sim: add SwingLine and ScanSwingLine

Pure scan function: walks a direction collecting Pushables, terminates
at the first BlocksMovement/BlocksPush, transparent to sunk heavy boxes
and other Walkable terrain. Returns the line + terminus + direction.

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

Task 4: SwingOp record and ApplySwingOps (Move + Block only)

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim/Swing/SwingOp.cs
  • Create: muscleman-godot4/src/Muscleman.Sim/Swing/ApplySwingOps.cs

SwingOp is the discriminated union of atomic swing operations. This task implements the Move and Block variants only. Open and Combine (used for hammer break, nutcrack, key combine) are stubbed in the type but unimplemented in the applier — they’ll throw. The applier’s job is to mechanically translate ops into world mutations and events.

  • Step 1: Define SwingOp

Path: muscleman-godot4/src/Muscleman.Sim/Swing/SwingOp.cs

using Muscleman.Core.Enums;
using Muscleman.Core.Grid;

namespace Muscleman.Sim.Swing;

/// <summary>
/// Atomic operations produced by <c>ResolveSwing</c> and consumed by <c>ApplySwingOps</c>.
/// A single swing produces a flat list of these — no compound types, no nested ops.
/// </summary>
public abstract record SwingOp
{
    /// <summary>An entity slides one cell in the swing's exit direction.</summary>
    public sealed record Move(int Entity, GridDir Dir) : SwingOp;

    /// <summary>The swing rebounded against this entity. No state change; presenter cue only.</summary>
    public sealed record Block(int Blocker) : SwingOp;

    /// <summary>An entity is opened (Smashable/Openable). Not implemented this plan.</summary>
    public sealed record Open(int Container) : SwingOp;

    /// <summary>Two entities merge to produce a third. Not implemented this plan.</summary>
    public sealed record Combine(int A, int B, EntityRecipe Result) : SwingOp;
}
  • Step 2: Implement ApplySwingOps with tests

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

using Muscleman.Core.Enums;
using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Muscleman.Sim;
using Muscleman.Sim.Swing;
using Xunit;
using FluentAssertions;

namespace Muscleman.Sim.Tests;

public class ApplySwingOpsTests
{
    [Fact]
    public void Apply_Move_op_relocates_entity_and_emits_Moved_with_Swing_cause()
    {
        var t = new TestWorld().Player(0, 0).Box(5, 5, "box");
        var events = new EventBatch();

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

        t.PosOf("box").Should().Be(new GridPos(6, 5));
        events.OfType<Moved>().Should().ContainSingle()
            .Which.Cause.Should().Be(MoveCause.Swing);
    }

    [Fact]
    public void Apply_Block_op_emits_no_state_change()
    {
        var t = new TestWorld().Player(0, 0).Wall(1, 0, name: "wall");
        var events = new EventBatch();

        ApplySwingOps.Apply(
            t.World,
            new List<SwingOp> { new SwingOp.Block(t.Named["wall"]) },
            events);

        // Block op contributes no Moved events — it's an animation hint only.
        events.OfType<Moved>().Should().BeEmpty();
    }

    [Fact]
    public void Apply_multiple_Move_ops_relocates_all_entities_in_reverse_order()
    {
        // Same atomicity discipline as PushSystem — apply in reverse so each destination
        // is empty when the move happens. Caller is expected to provide ops in line order.
        var t = new TestWorld().Player(0, 0).Box(1, 0, "b1").Box(2, 0, "b2");
        var events = new EventBatch();

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

        t.PosOf("b1").Should().Be(new GridPos(2, 0));
        t.PosOf("b2").Should().Be(new GridPos(3, 0));
        events.OfType<Moved>().Should().HaveCount(2);
    }

    [Fact]
    public void Apply_Open_op_throws_NotImplementedException_in_this_plan()
    {
        var t = new TestWorld().Player(0, 0).Box(1, 0, "box");
        var events = new EventBatch();

        var act = () => ApplySwingOps.Apply(
            t.World,
            new List<SwingOp> { new SwingOp.Open(t.Named["box"]) },
            events);

        act.Should().Throw<System.NotImplementedException>();
    }
}
  • Step 3: Run tests; confirm failure
dotnet test --filter FullyQualifiedName~ApplySwingOpsTests

Expected: build fails (SwingOp, ApplySwingOps don’t exist) — define them in steps 4–5.

  • Step 4: Implement ApplySwingOps

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

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

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 are applied in
    /// their natural order; for this plan, Open and Combine are not implemented and
    /// throw if encountered.
    /// </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 Block/Open/Combine).
        foreach (var op in ops)
        {
            switch (op)
            {
                case SwingOp.Move:
                    // Already applied above.
                    break;
                case SwingOp.Block:
                    // Animation/sound hint only — no state change. Future plans may emit a
                    // SwingBlocked event here for the presenter; for now, silent.
                    break;
                case SwingOp.Open:
                    throw new System.NotImplementedException(
                        "SwingOp.Open is not implemented in this plan; deferred to Phase 3.");
                case SwingOp.Combine:
                    throw new System.NotImplementedException(
                        "SwingOp.Combine is not implemented in this plan; deferred to Phase 3.");
            }
        }
    }

    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));
    }
}
  • Step 5: Run tests; confirm pass
dotnet test --filter FullyQualifiedName~ApplySwingOpsTests

Expected: all 4 ApplySwingOpsTests pass; total 68.

  • Step 6: Commit
cd /home/henri/muscleman
git add muscleman-godot4
git commit -m "sim: add SwingOp and ApplySwingOps (Move + Block; Open/Combine deferred)

Move ops apply in reverse list order to keep destination cells empty
during atomic commit. Block ops are presenter-cue only. Open/Combine
throw NotImplementedException — deferred to Phase 3 (transforms).

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

Task 5: ResolveSwing (slide and block branches)

Files:

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

The resolver is a pure function: (SwingLine, EntityId? held, World) → List<SwingOp>. This task implements two branches per spec §4.5:

  • Slide branch (terminus is null OR has WallWeak tag, i.e. soft terminus): produce Move ops for every entity in the line.
  • Block branch (anything else, including Heavy in the line, hard terminus, etc.): produce [Block(terminus)].

Hammer/squeeze/combine branches throw NotImplementedException (Phase 3).

  • Step 1: Write the failing tests

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

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

namespace Muscleman.Sim.Tests;

public class ResolveSwingTests
{
    [Fact]
    public void Empty_line_with_null_terminus_yields_no_ops()
    {
        var t = new TestWorld().Player(0, 0);
        var line = new SwingLine(new List<int>(), Terminus: null, Dir: GridDir.Right);

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

        ops.Should().BeEmpty();
    }

    [Fact]
    public void Empty_line_with_hard_terminus_yields_a_single_Block_op()
    {
        var t = new TestWorld().Player(0, 0).Wall(1, 0, name: "wall");
        var line = new SwingLine(new List<int>(), Terminus: t.Named["wall"], Dir: GridDir.Right);

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

        ops.Should().ContainSingle();
        ops[0].Should().BeOfType<SwingOp.Block>()
            .Which.Blocker.Should().Be(t.Named["wall"]);
    }

    [Fact]
    public void Line_with_null_terminus_yields_Move_for_each_entity()
    {
        var t = new TestWorld().Player(0, 0).Box(1, 0, "b1").Box(2, 0, "b2");
        var line = new SwingLine(
            new List<int> { t.Named["b1"], t.Named["b2"] },
            Terminus: null,
            Dir: GridDir.Right);

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

        ops.Should().HaveCount(2);
        ops[0].Should().BeOfType<SwingOp.Move>().Which.Entity.Should().Be(t.Named["b1"]);
        ops[1].Should().BeOfType<SwingOp.Move>().Which.Entity.Should().Be(t.Named["b2"]);
        ((SwingOp.Move)ops[0]).Dir.Should().Be(GridDir.Right);
    }

    [Fact]
    public void Line_with_WallWeak_terminus_yields_Move_for_each_entity()
    {
        // WallWeak is soft terminus — slide proceeds.
        var t = new TestWorld().Player(0, 0).Box(1, 0, "b1").Wall(2, 0, strong: false, name: "weak");
        var line = new SwingLine(
            new List<int> { t.Named["b1"] },
            Terminus: t.Named["weak"],
            Dir: GridDir.Right);

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

        ops.Should().ContainSingle();
        ops[0].Should().BeOfType<SwingOp.Move>();
    }

    [Fact]
    public void Line_with_WallStrong_terminus_yields_a_single_Block_op_only()
    {
        var t = new TestWorld().Player(0, 0).Box(1, 0, "b1").Wall(2, 0, strong: true, name: "strong");
        var line = new SwingLine(
            new List<int> { t.Named["b1"] },
            Terminus: t.Named["strong"],
            Dir: GridDir.Right);

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

        // Hard terminus, no transforms in this plan: just a Block.
        // (Phase 3 will produce Open ops here when held entity has Hammer.)
        ops.Should().ContainSingle();
        ops[0].Should().BeOfType<SwingOp.Block>()
            .Which.Blocker.Should().Be(t.Named["strong"]);
    }

    [Fact]
    public void Line_containing_a_Heavy_with_hard_terminus_just_blocks()
    {
        // Phase 3 will look for nutcrack/combine patterns here. For Phase 2: just block.
        var t = new TestWorld().Player(0, 0).HeavyBox(1, 0, "h1").Wall(2, 0, name: "wall");
        var line = new SwingLine(
            new List<int> { t.Named["h1"] },
            Terminus: t.Named["wall"],
            Dir: GridDir.Right);

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

        ops.Should().ContainSingle();
        ops[0].Should().BeOfType<SwingOp.Block>();
    }

    [Fact]
    public void Line_of_Heavy_with_soft_terminus_still_slides()
    {
        // Per spec §4.5: heavy boxes slide in a line; this is the bridge-building mechanic.
        var t = new TestWorld().Player(0, 0).HeavyBox(1, 0, "h1").HeavyBox(2, 0, "h2");
        var line = new SwingLine(
            new List<int> { t.Named["h1"], t.Named["h2"] },
            Terminus: null,
            Dir: GridDir.Right);

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

        ops.Should().HaveCount(2);
        ops.Should().AllBeOfType<SwingOp.Move>();
    }
}
  • Step 2: Run tests; confirm failure
dotnet test --filter FullyQualifiedName~ResolveSwingTests

Expected: build fails (ResolveSwing doesn’t exist).

  • Step 3: Implement ResolveSwing

Path: muscleman-godot4/src/Muscleman.Sim/Swing/ResolveSwing.cs

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.
    ///
    /// This plan implements two branches:
    ///   - Slide: terminus is null OR has WallWeak (soft). All entities in the line emit Move ops.
    ///   - Block: anything else. Single Block op naming the terminus.
    ///
    /// Phase 3 will add hammer-break, nutcrack, and key-combine branches.
    /// </summary>
    public static IReadOnlyList<SwingOp> Resolve(SwingLine line, int? held, World w)
    {
        var soft = IsSoftTerminus(line.Terminus, w);

        if (soft)
        {
            // Slide branch: every entity in line moves one cell in the swing direction.
            // (Heavy entities move too — they're unliftable, not unmovable.)
            var ops = new List<SwingOp>(line.Pushables.Count);
            foreach (var id in line.Pushables)
                ops.Add(new SwingOp.Move(id, line.Dir));
            return ops;
        }

        // Block branch — terminus is hard. Phase 3 will inspect the line for squeeze
        // patterns and check the held entity for Hammer here. For now, just block.
        if (line.Terminus is int t)
            return new List<SwingOp> { new SwingOp.Block(t) };

        // Defensive: hard-terminus branch without a terminus shouldn't occur, but if it
        // does, return empty rather than throwing — the swing was a no-op.
        return new List<SwingOp>();
    }

    private static bool IsSoftTerminus(int? terminus, World w)
    {
        if (terminus is null) return true;                // open cell
        if (w.HasTag<WallWeak>(terminus.Value)) return true; // ruined wall — slide passes/lands
        return false;
    }
}
  • Step 4: Run tests; confirm pass
dotnet test --filter FullyQualifiedName~ResolveSwingTests

Expected: 7 ResolveSwingTests pass; total 75.

  • Step 5: Commit
cd /home/henri/muscleman
git add muscleman-godot4
git commit -m "sim: add ResolveSwing — slide and block branches

Pure function: SwingLine + held + world -> List<SwingOp>. Soft terminus
(empty cell or WallWeak) yields Move ops for all line entities; hard
terminus yields a single Block. Hammer/nutcrack/combine deferred.

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

Task 6: SwingSystem orchestration

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim/Systems/SwingSystem.cs
  • Modify: muscleman-godot4/src/Muscleman.Sim/Systems/PlayerActionSystem.cs (route Input.SwingLeft/Right to SwingSystem)
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/SwingSystemTests.cs

SwingSystem.Resolve ties everything together. When the player swings:

  1. If nothing is held, the swing is a no-op.
  2. Compute the rotation: rot = facing.Rotate{Right|Left}() — but wait, the rotation isn’t of the facing direction. Re-examining: the held entity is at playerPos + (heldPos - playerPos). The “to_grabbed” direction is (heldPos - playerPos) as a unit GridDir. Swing-right rotates that unit clockwise; swing-left counter-clockwise. The held’s NEW position is playerPos + rotated_to_grabbed. The swing-line scan starts AT that new position (the cell the held wants to enter) and continues in the swing’s “exit” direction = rotate_again(to_grabbed_rotated_once).

Wait — the resolution is more nuanced because the rotation is around the player. Let me write it precisely in code.

  1. Compute swing-line start = newHeldPos = playerPos + rotated. Compute swing exit direction exitDir = rotated.Rotate{Right|Left}() (rotate once more in the swing direction).
  2. Determine what’s at newHeldPos:
    • Empty: held just moves there. No line scan needed.
    • Has BlocksMovement non-Pushable (wall, closed door): rebound. No movement.
    • Has Pushable (or any other entity-occupied case): scan line in exitDir from newHeldPos, resolve, apply. If slide succeeded, held also lands at newHeldPos.
  3. Emit Swung(playerId, arcFrom, arcVia, arcTo) event with arc info for the presenter.
  • Step 1: Write the failing tests

Path: muscleman-godot4/src/Muscleman.Sim.Tests/SwingSystemTests.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 Xunit;
using FluentAssertions;

namespace Muscleman.Sim.Tests;

public class SwingSystemTests
{
    private static TestWorld SetupHoldingRight()
    {
        // Player at (5,5), holds Box at (6,5) (held is to player's Right).
        var t = new TestWorld().Player(5, 5).Box(6, 5, "held");
        t.World.Set(t.PlayerId, new Facing(GridDir.Right));
        StepRunner.Step(t.World, t.PlayerId, new Input.Grab());
        return t;
    }

    [Fact]
    public void Swing_with_nothing_held_is_a_no_op()
    {
        var t = new TestWorld().Player(5, 5);
        var batch = StepRunner.Step(t.World, t.PlayerId, new Input.SwingRight());

        batch.OfType<Swung>().Should().BeEmpty();
        batch.OfType<Moved>().Should().BeEmpty();
    }

    [Fact]
    public void Swing_right_rotates_held_box_clockwise_into_open_cell()
    {
        // Box at (6,5) (Right of player). SwingRight → box moves to (5,6) (Down of player).
        var t = SetupHoldingRight();
        var batch = StepRunner.Step(t.World, t.PlayerId, new Input.SwingRight());

        t.PosOf("held").Should().Be(new GridPos(5, 6));
        batch.OfType<Swung>().Should().ContainSingle();
        batch.OfType<Moved>().Should().Contain(e =>
            e.EntityId == t.Named["held"] && e.Cause == MoveCause.Swing);
    }

    [Fact]
    public void Swing_left_rotates_held_box_counter_clockwise_into_open_cell()
    {
        // Box at (6,5) (Right of player). SwingLeft → box moves to (5,4) (Up of player).
        var t = SetupHoldingRight();
        var batch = StepRunner.Step(t.World, t.PlayerId, new Input.SwingLeft());

        t.PosOf("held").Should().Be(new GridPos(5, 4));
        batch.OfType<Swung>().Should().ContainSingle();
    }

    [Fact]
    public void Swing_into_strong_wall_is_blocked_held_does_not_move()
    {
        // Box at (6,5) Right of player. SwingRight wants box at (5,6) Down. Wall there → block.
        var t = SetupHoldingRight();
        // Wall at swing destination.
        t.Wall(5, 6, strong: true);

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

        t.PosOf("held").Should().Be(new GridPos(6, 5));  // didn't move
    }

    [Fact]
    public void Swing_into_a_box_slides_the_line_and_held_lands_in_freed_cell()
    {
        // Held at (6,5). Swing-right wants destination (5,6). Box at (5,6), and (4,6) empty.
        // Line scan from (5,6) in exit direction Left: collects box at (5,6); cell (4,6) empty.
        // Slide: box at (5,6) moves to (4,6). Held lands at (5,6).
        var t = SetupHoldingRight();
        t.Box(5, 6, "obstacle");

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

        t.PosOf("held").Should().Be(new GridPos(5, 6));
        t.PosOf("obstacle").Should().Be(new GridPos(4, 6));
    }

    [Fact]
    public void Swing_into_a_box_with_hard_terminus_does_not_move()
    {
        // Held at (6,5). Swing-right destination (5,6). Box at (5,6); strong wall at (4,6).
        // Line scan: [box]; terminus = strong wall. Block branch — held doesn't move.
        var t = SetupHoldingRight();
        t.Box(5, 6, "obstacle");
        t.Wall(4, 6, strong: true);

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

        t.PosOf("held").Should().Be(new GridPos(6, 5));     // didn't move
        t.PosOf("obstacle").Should().Be(new GridPos(5, 6)); // didn't move either
    }

    [Fact]
    public void Successful_swing_emits_Swung_with_correct_arc_positions()
    {
        // Held at (6,5). Swing-right → arc from (6,5) via (6,6) corner to (5,6).
        var t = SetupHoldingRight();
        var batch = StepRunner.Step(t.World, t.PlayerId, new Input.SwingRight());

        var swung = batch.OfType<Swung>().Should().ContainSingle().Subject;
        swung.PlayerId.Should().Be(t.PlayerId);
        swung.ArcFrom.Should().Be(new GridPos(6, 5));
        swung.ArcVia.Should().Be(new GridPos(6, 6));   // corner cell
        swung.ArcTo.Should().Be(new GridPos(5, 6));
    }
}
  • Step 2: Run tests; confirm failure
dotnet test --filter FullyQualifiedName~SwingSystemTests

Expected: build fails (SwingSystem doesn’t exist) and the SwingLeft/SwingRight cases in PlayerActionSystem are still no-ops.

  • Step 3: Implement SwingSystem

Path: muscleman-godot4/src/Muscleman.Sim/Systems/SwingSystem.cs

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

namespace Muscleman.Sim.Systems;

public static class SwingSystem
{
    /// <summary>
    /// Resolves a player swing (left or right) into world mutations + events.
    ///
    /// Geometry: held entity is at <c>playerPos + toGrabbed</c> where <c>toGrabbed</c> is
    /// a unit GridDir. Swinging clockwise (right) rotates toGrabbed via <see cref="GridDirExtensions.RotateRight"/>;
    /// counter-clockwise (left) via RotateLeft. The held's new position is
    /// <c>playerPos + rotatedToGrabbed</c>. The swing's "exit direction" — the direction
    /// the line at the new position would slide if pushed — is one more rotation:
    ///   exitDir = rotatedToGrabbed.Rotate{Right|Left}() (same direction as the swing).
    /// </summary>
    public static void Resolve(World w, int playerId, bool right, EventBatch events)
    {
        var heldId = w.Get<Holding>(playerId).EntityId;
        if (heldId is not int held) return;

        var playerPos = w.Get<Position>(playerId).Pos;
        var heldPos = w.Get<Position>(held).Pos;

        // Convert (heldPos - playerPos) into a unit GridDir. We accept that this only
        // works for adjacent cells — the held entity is always at a 4-neighbour cell.
        var toGrabbed = HeldDirection(heldPos - playerPos);

        var rotated = right ? toGrabbed.RotateRight() : toGrabbed.RotateLeft();
        var exitDir = right ? rotated.RotateRight() : rotated.RotateLeft();

        var newHeldPos = playerPos + rotated.ToOffset();
        var arcVia = playerPos + toGrabbed.ToOffset() + rotated.ToOffset();

        // Determine what's at newHeldPos.
        var pushableHere = w.Index.PushableAt(newHeldPos);
        var blockerHere = AnyBlockerAt(w, newHeldPos);

        if (pushableHere is null && blockerHere is null)
        {
            // Empty (or only walkable terrain) — held simply lands.
            CommitHeldMove(w, held, heldPos, newHeldPos, events);
            events.Add(new Swung(playerId, heldPos, arcVia, newHeldPos));
            return;
        }

        if (pushableHere is null && blockerHere is int b)
        {
            // Non-pushable blocker (strong wall, closed door, weak wall)…
            // For weak wall, the rule is "swings can pass over WallWeak". But the held entity
            // doesn't have a line in front of it; it just wants to land. WallWeak at newHeldPos
            // doesn't block walking-onto-the-wall via a swing — held co-occupies. Treat WallWeak
            // as soft-landing.
            if (w.HasTag<WallWeak>(b))
            {
                CommitHeldMove(w, held, heldPos, newHeldPos, events);
                events.Add(new Swung(playerId, heldPos, arcVia, newHeldPos));
                return;
            }
            // Hard blocker — rebound. No movement, no Swung event (presenter has no swing
            // to render). Future plans may emit a SwingBlocked here for rebound animation.
            return;
        }

        // newHeldPos contains a Pushable. Scan the line in exitDir, resolve, apply.
        var line = ScanSwingLine.Scan(w, newHeldPos, exitDir);
        var ops = ResolveSwing.Resolve(line, held, w);
        ApplySwingOps.Apply(w, ops, events);

        // Did the line slide? It slid iff the resolver produced any Move ops.
        var lineSlid = false;
        foreach (var op in ops) { if (op is SwingOp.Move) { lineSlid = true; break; } }

        if (lineSlid)
        {
            // Line slid — newHeldPos is now empty; held lands.
            CommitHeldMove(w, held, heldPos, newHeldPos, events);
            events.Add(new Swung(playerId, heldPos, arcVia, newHeldPos));
        }
        // else: line blocked, held stays put. No Swung event.
    }

    private static GridDir HeldDirection(GridPos delta)
    {
        if (delta.X > 0) return GridDir.Right;
        if (delta.X < 0) return GridDir.Left;
        if (delta.Y < 0) return GridDir.Up;
        return GridDir.Down;
    }

    private static int? AnyBlockerAt(World w, GridPos cell)
    {
        foreach (var id in w.Index.AllAt(cell))
        {
            if (w.HasTag<BlocksMovement>(id) || w.HasTag<BlocksPush>(id))
                return id;
        }
        return null;
    }

    private static void CommitHeldMove(World w, int heldId, GridPos from, GridPos to, EventBatch events)
    {
        if (from == to) return;
        w.Move(heldId, to);
        events.Add(new Moved(heldId, from, to, MoveCause.Swing));
    }
}
  • Step 4: Wire SwingSystem into PlayerActionSystem

Replace the Resolve method in muscleman-godot4/src/Muscleman.Sim/Systems/PlayerActionSystem.cs so the SwingLeft/SwingRight cases dispatch:

public static void Resolve(World w, int playerId, Input input, EventBatch events)
{
    switch (input)
    {
        case Input.Move move:
            ResolveMove(w, playerId, move.Dir, events);
            break;

        case Input.Grab:
            ResolveGrab(w, playerId, events);
            break;

        case Input.Drop:
            ResolveDrop(w, playerId, events);
            break;

        case Input.SwingLeft:
            SwingSystem.Resolve(w, playerId, right: false, events);
            break;

        case Input.SwingRight:
            SwingSystem.Resolve(w, playerId, right: true, events);
            break;

        case Input.Wait:
            // Intentionally no-op.
            break;
    }
}

The other private methods (ResolveMove, ResolveGrab, ResolveDrop, IsBlockedIgnoring) stay unchanged.

  • Step 5: Run tests; confirm pass
dotnet test

Expected: all 7 SwingSystemTests pass; all 75 previous still pass. Total: 82.

  • Step 6: Commit
cd /home/henri/muscleman
git add muscleman-godot4
git commit -m "sim: add SwingSystem — wires scan, resolve, apply for player swings

Held rotates 90deg around the player. Empty/WallWeak destinations let the
held land; Pushable destinations trigger a line scan in the exit direction
and slide if terminus is soft; hard blockers rebound silently. Emits
Swung with from/via/to positions for presenter arc rendering.

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

Task 7: Final integration + tag

Files: none new.

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

Expected: Passed: 82, Failed: 0. Breakdown:

  • Phase 1 carried over: 41 tests
  • Task 0 (grab/drop): +7 → 48
  • Task 1 (held walk): +5 → 53
  • Task 2 (held push): +4 → 57
  • Task 3 (scan): +7 → 64
  • Task 4 (apply): +4 → 68
  • Task 5 (resolve): +7 → 75
  • Task 6 (swing system): +7 → 82

If any test fails, fix the underlying issue before proceeding.

  • 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: Tag the milestone
cd /home/henri/muscleman
git tag -a sim-grab-and-swing-slide -m "Sim grab/drop, walk-while-holding, push-while-holding, swing slide branch"
  • Step 4: Verify the tag
git tag -l sim-grab-and-swing-slide
git log --oneline sim-grab-and-swing-slide -1

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


Definition of Done

  • dotnet test from muscleman-godot4/ passes 82 tests.
  • The sim-grab-and-swing-slide tag points to a commit containing all of the above tasks merged.
  • The sim is still genuinely Godot-free.
  • No // TODO, // FIXME outside of the explicit NotImplementedException for SwingOp.Open/Combine in ApplySwingOps (intentional — Phase 3 will implement them).

What’s Next

The next plan in the sequence is Phase 3: swing transforms — adds the hammer-break, nutcrack, and key-combine branches to ResolveSwing, plus the corresponding Open and Combine ops in ApplySwingOps. Approximate scope: 6–8 tasks. The pure-resolver design from this plan makes adding rules a matter of pattern-matching on the line + tags, no system rewrites.