Re: [QUIZ] Perl 'Hard' Quiz of the Whatever #2008-12-28 - Symmetric Sokoban

Shlomi Fish <shlomif-ik1l9ssToec+JF/[email protected]> Fri, 04 Jan 2008 19:13:16 +0200
Newsgroups gmane.comp.lang.perl.qotw.discuss
Message-ID <[email protected]>
On Friday 28 December 2007, Shlomi Fish wrote:
> IMPORTANT: Please do not post solutions, hints, or other spoilers
> until at least 60 hours after the date of this message.  Thanks.
>
> I was told the "What does this code do?" quizzes were not as good as a new
> programming task, so here's a more traditional QOTW that I came up with. In
> this quiz you'll try to solve the following Sokoban (
> http://en.wikipedia.org/wiki/Sokoban ) puzzle using Perl:
>
> {{{{{{{{{{{{{{
>   ####
>   #  #
>   #  ####
> ###$.$  #
> #  .@.  #
> #  $.$###
> ####  #
>    #  #
>    ####
> }}}}}}}}}}}}}}
>

Hi all!

OK, my complete solution to the QOTW can be found here:

http://www.shlomifish.org/Files/files/code/perl/qotw/Shlomif-Sokoban-Solver-0.01.tar.gz

It includes some tests (though, not for the solving algorithm itself), a 
module and some tests.

My scheme was:

1. Store the board as a vector of 2-bits, and the states as 2 bits (locations 
of the boxes and the space accessible to the player). vec() was used for the 
bit-fiddling.

2. Make use of the symmetry by only storing and looking up the least-valued 
rotated board. This makes it unsuitable for general Sokoban solutions, at 
least until I adapt the code to detect a possible symmetry.

3. Use a BFS search to find the solution, and a BFS search to find the 
accessible places for a player to go.

------------------

There's still room for many optimisations and many dead ends are not detected 
soon enough.

The program runs in just under two minutes on my Mandriva Cooker system on a 
P4-2.4GHz machine.

I'm including here the code of my main module which implements almost all of 
the logic of the solver.

Regards,

	Shlomi Fish

<<<<<<<<<<<<<<<<<<<<<<<<<
package Shlomif::Sokoban::Solver::Board;

use strict;
use warnings;

=head1 NAME

Shlomif::Sokoban::Solver::Board - a board for the sokosolver.

=head1 SYNOPSIS

For internal use by the Sokoban solver. See the test files.

=cut

use List::Util qw(max);

use Object::Tiny qw/
    height
    width
    _collect
    _data
    _dests
    _init_state
    _queue
/;

my $dest_place_bits = 0x1;
my $wall_bits = 0x2;

my $box_bits = 0x1;
my $reachable_bits = 0x2;

=head1 METHODS

=head2 load($board)

Loads a board in standard Sokoban notation.

=cut

sub _calc_offset
{
    my ($self, $x, $y) = @_;

    return $y*$self->width()+$x;
}

sub load
{
    my ($pkg, $contents) = @_;

    # Remove trailing whitespace.
    $contents =~ s{(\s*\n)+\z}{}ms; 

    # Remove trailing whitespace from lines.
    $contents =~ s{\s+$}{}gms;

    my @lines = (map { [ split(//, $_) ] } split(/\n/, $contents));

    my $data = "";

    my $init_state = "";
    my $init_pos;

    my $self = 
        $pkg->new(
            height => scalar(@lines),
            width => max(map { scalar(@$_) } @lines),
            _data => \$data,
            _dests => [],
            _init_state => \$init_state,
            _collect => +{},
            _queue => [],
        );



    foreach my $y (0 .. $#lines)
    {
        my $l = $lines[$y];

        foreach my $x (0 .. $#$l)
        {
            my $offset = $self->_calc_offset($x, $y);

            # Initialise the init_state block to the default.
            vec($init_state, $offset, 2) = 0;
            if ($l->[$x] eq "#")
            {
                vec(${$self->_data()}, $offset, 2) = $wall_bits;
            }
            elsif ($l->[$x] eq ".")
            {
                vec(${$self->_data()}, $offset, 2) = $dest_place_bits;
                push @{$self->_dests()}, [$x, $y];
            }
            else
            {
                vec(${$self->_data()}, $offset, 2) = 0;
                if ($l->[$x] eq '$')
                {
                    vec($init_state, $offset, 2) = $box_bits;
                }
                elsif ($l->[$x] eq '@')
                {
                    $init_pos = [$x, $y];
                }
            }
        }
    }

    if (!defined($init_pos))
    {
        die "The initial position of the player was not defined.";
    }

    $self->_mark_reachable(\$init_state, @$init_pos);

    return $self;
}

=head2 $board->is_wall($x,$y)

Returns if the block at the position $x,$y is a wall.

=cut

sub is_wall
{
    my ($self, $x, $y) = @_;

    return (vec(${$self->_data()}, $self->_calc_offset($x,$y), 2) == 
$wall_bits);
}

=head2 $board->is_dest($x,$y)

Returns if the block at the position $x,$y is a destination block.

=cut

sub is_dest
{
    my ($self, $x, $y) = @_;

    return (vec(${$self->_data()}, $self->_calc_offset($x,$y), 2)
            == $dest_place_bits
        );
}

=head2 $board->is_box($s_ref, $x, $y)

Is ($x,$y) in the state referenced by $s_ref a box?

=cut

sub is_box
{
    my ($self, $s_ref, $x, $y) = @_;
    return (vec($$s_ref, $self->_calc_offset($x,$y), 2) == $box_bits);
}

=head2 $board->is_reachable($s_ref, $x, $y)

Is ($x,$y) in the state referenced by $s_ref reachable by the player?

=cut

sub is_reachable
{
    my ($self, $s_ref, $x, $y) = @_;
    return (vec($$s_ref, $self->_calc_offset($x,$y), 2) == $reachable_bits);
}

sub _mark_reachable
{
    my ($self, $s_ref, $start_x, $start_y) = @_;

    # Breadth-first search to find all the reachable positions in the board.
    my @to_check =([$start_x, $start_y]);

    while (my $pos = shift(@to_check))
    {
        # Mark as reachable.
        vec($$s_ref, $self->_calc_offset(@$pos), 2) = $reachable_bits;

        foreach my $offset ([-1,0],[1,0],[0,-1],[0,1])
        {
            my @new_pos = ($pos->[0]+$offset->[0], $pos->[1]+$offset->[1]);
            if (   ($new_pos[0] >= 0)
                && ($new_pos[1] >= 0)
                && ($new_pos[0] < $self->width())
                && ($new_pos[1] < $self->height())
                && (! $self->is_wall(@new_pos))
                && (! $self->is_box($s_ref, @new_pos))
                && (! $self->is_reachable($s_ref, @new_pos))
               )
            {
                push @to_check, \@new_pos;
            }
        }
    }

    return;
}

sub _rotate
{
    my ($self, $s_ref) = @_;

    my $ret = "";

    my $width = $self->width()-1;
    my $height = $self->height()-1;

    for my $x (0 .. $width)
    {
        for my $y (0 .. $height)
        {
            vec($ret, $self->_calc_offset($y, $width-$x), 2) =
                vec($$s_ref, $self->_calc_offset($x,$y), 2);
        }
    }

    return \$ret;
}

# Get the minimal rotation permutation
sub _get_min_rot_perm
{
    my ($self, $s_ref) = @_;

    # Find the minimal board by its rotation permutations.
    my $min_rot_times = 0;
    my $min_rot_board = $s_ref;
    foreach my $r (1 .. 3)
    {
        my $new = $self->_rotate($s_ref);
        if ($$new lt $$min_rot_board)
        {
            $min_rot_times = $r;
            $min_rot_board = $new;
        }
        $s_ref = $new;
    }

    return ($min_rot_times, $min_rot_board);
}

sub _derive
{
    my ($self, $state_ref, $box_xy, $push_to_xy) = @_;

    my $new_state = "";

    for my $y (0 .. $self->height()-1)
    {
        for my $x (0 .. $self->width()-1)
        {
            my $offset = $self->_calc_offset($x, $y);
            if ($self->is_box($state_ref, $x, $y))
            {
                vec($new_state, $offset, 2) = $box_bits;
            }
            else
            {
                vec($new_state, $offset, 2) = 0;
            }
        }
    }

    # Move the new box.
    vec($new_state, $self->_calc_offset(@$box_xy), 2) = 0;
    vec($new_state, $self->_calc_offset(@$push_to_xy), 2) = $box_bits;

    # Mark the reachable bits.
    $self->_mark_reachable(\$new_state, @$box_xy);

    return \$new_state;
}

sub _output
{
    my ($self, $s_ref) = @_;

    for my $y (0 .. ($self->height()-1))
    {
        for my $x (0 .. ($self->width()-1))
        {
            print   $self->is_wall($x, $y) ? "#"
                  : $self->is_box($s_ref, $x, $y)  ? '$'
                  : $self->is_dest($x, $y) ? "."
                  : " "
                  ;
        }
        print "\n";
    }
    print "\n";
}

sub _is_final
{
    my ($self, $s_ref) = @_;

    foreach my $d (@{$self->_dests()})
    {
        if (! $self->is_box($s_ref, @$d))
        {
            return 0;
        }
    }
    return 1;
}

sub _try_to_move_box
{
    my ($self, $state_ref, $x, $y) = @_;

    for my $offset ([-1,0],[1,0],[0,-1],[0,1])
    {
        my @push_to = ($x+$offset->[0], $y+$offset->[1]);
        my @push_from = ($x-$offset->[0], $y-$offset->[1]);
        
        if (   (! $self->is_wall(@push_to))
            && (! $self->is_box($state_ref, @push_to))
            && $self->is_reachable($state_ref, @push_from)
           )
        {
            # We can push.
            my $new_state_ref =
                $self->_derive($state_ref, [$x, $y], \@push_to)
                ;

            # Print it - this is temporary for debugging.
            # (Now commented out.)
            # $self->_output($new_state_ref);

            # Else - register it and proceed.
            
            my ($rot_idx, $rot_state) =
                $self->_get_min_rot_perm($new_state_ref);
            if (exists($self->_collect()->{$$rot_state}))
            {
                # Do nothing
            }
            else
            {
                $self->_collect()->{$$rot_state} =
                {
                    r => (($rot_idx+$self->_collect()->{$$state_ref}->{r})%4),
                    p => $state_ref
                };
                if ($self->_is_final($rot_state))
                {
                    return $rot_state;
                }
                push @{$self->_queue()}, $rot_state;
            }
            
        }
    }

    return;
}

sub _trace_solution
{
    my ($self, $final_state) = @_;

    my @solution;

    {
        my $state = $final_state;

        while (defined($state))
        {
            push @solution, $state;
            $state = $self->_collect->{$$state}->{p};
        }
    }

    foreach my $state (reverse(@solution))
    {
        my $r = $self->_collect->{$$state}->{r};

        # Normalize the state from its rotated position.
        my $rot_state = $state;
        while ($r%4 != 0)
        {
            $rot_state = $self->_rotate($rot_state);
            $r++;
        }

        $self->_output($rot_state);
    }
}

=head2 $board->solve()

Actually solve the board.

=cut

sub solve
{
    my $self = shift;

    my $s_ref = $self->_init_state();

    my ($rot_idx, $rot_state) = $self->_get_min_rot_perm($s_ref);

    $self->_collect()->{$$rot_state} = { r => $rot_idx, p => undef() };

    push @{$self->_queue()}, $rot_state;

    my $w = $self->width()-1;
    my $h = $self->height()-1;

    while (my $state_ref = pop(@{$self->_queue()}))
    {
        for my $y (0 .. $h)
        {
            for my $x (0 .. $w)
            {
                if ($self->is_box($state_ref, $x, $y))
                {
                    my $final = $self->_try_to_move_box($state_ref, $x, $y);

                    if (defined($final))
                    {
                        $self->_trace_solution($final);

                        return $final;
                    }
                }
            }
        }
    }
}

=head2 width()

Returns the width of the board.

=head2 height()

Returns the height of the board.

=cut

1;
>>>>>>>>>>>>>>>>>>>>>>>>>
---------------------------------------------------------------------
Shlomi Fish      shlomif-ik1l9ssToec+JF/[email protected]
Homepage:        http://www.shlomifish.org/

I'm not an actor - I just play one on T.V.