p4-moritz-2

Download the raw code.

#!/usr/bin/env perl6
use v6;

# in a board, True stands for an empty slot and False for a filled slot
#
# I call a consecutive run of at least 2 empty slots an "island".

# returns a list of pairs, which .key being the first index of the island,
# and .value the length
sub islands(@b) {
    return () unless any @b;
    gather {
        my $island-start = @b.pairs.first(*.value).key;
        for $island-start + 1 .. @b.end -> $i {
            if !@b[$i] {
                given $i - $island-start {
                    take $island-start => $_ if $_ >= 2;
                }
                $island-start = $i + 1;
            }
        }
        # since @b[*-1] is never empty, we don't have to check for islands
        # at the end of @b.
    }
}

sub game-ended(@board) {
    return False if all @board;
    not islands(@board.rotate(1 + @board.pairs.first(!*.value).key));
}

sub next-move(@board) {
    if all(@board) {
        # without loss of generality etc.
        return 0;
    }
    # get around considering circular boundary conditions by a simple shift
    # the +1 ensures that the alst array item is never empty.
    my $offset = 1 + @board.pairs.first(!*.value).key;
    my @b = @board.rotate($offset);

    # backward transformation
    my &return-shifted = -> $x as Int {
#        say "return (unshifted) $x";
        return ($x + $offset) % @board;
    }
    my @islands := islands(@b);

    if @islands == 1 && @islands[0].value %% 2 {
        # we can just slice the island into two, and then always do
        # the same move as the other player in the other half
        return-shifted @islands[0].key + @islands[0].value div 2 - 1
    }

    sub mmin(Int $x) { ($x + 1) div 3 }
    sub mmax(Int $x) { $x div 2 }

    my $min-moves = [+] map &mmin, @islands>>.value;
    my $max-moves = [+] map &mmax, @islands>>.value;

    sub first-choice {
        @islands.first({mmin(.value) != mmax(.value)});
    }

    given $max-moves - $min-moves {
        when * % 2 == 1 {
            return-shifted(first-choice.key + $min-moves % 2);
        }
        default {
            if $max-moves % 2 == 1 {
                # we'd like to preserve the max number of moves
                return-shifted(@islands[0].key)
            } else {
                # reduce the number of choices by one
                my $p = first-choice;
                if $p {
                    return-shifted($p.key);
                } else {
                    # there's nothing we can do anyway
                    return-shifted(@islands[0].key);
                }
            }
        }
    }
}

sub computer-move(@board) {
    my $next = next-move(@board);
    my $second = ($next + 1) % @board;
    say "computer takes $next,$second";
    return ($next, $second);
}

sub prompt-for-human-move(@board) {
    say "human to move: ", join(',', @board.pairs.grep(*.value)>>.key);
    my @next = get.chomp.split(/\, \s*/)>>.Int;
}


sub play(Int $size where { $_ >= 2 }, $start where { $_ eq any <computer human> }, :&computer = &computer-move, :&human = &prompt-for-human-move) {

    my &abort := -> $reason = "aborted" { say $reason; return };
    my @player = <computer human>;
    my %other = @player Z=> @player.rotate;
    my %move-maker = :&computer, :&human;
#    say %move-maker.perl;
    my @board = True xx $size;

    my $player = $start;
    while !game-ended(@board) {
#        say (:$player).perl;
        my @next = %move-maker{$player}.(@board);
        if @next != 2 {
            abort 'Illegal move (not two numbers)';
        } elsif [==] @next {
            abort 'Illegal move: numbers are the same';
        } elsif (@next[0] + 1) % $size != @next[1] {
            abort 'Illegal move: numbers are not adjacent';
        } elsif not 0 <= all(@next) < $size {
            abort "Illegal numbers (must be between 0 and {$size-1}";
        } elsif not all @board[@next] {
            abort "Sorry, at least one of those stones was already taken";
        }
        @board[@next] = False xx 2;
        $player = %other{$player};
    }
    say %other{$player}, " wins";
}

multi sub MAIN() {
    my &abort := -> $reason = "aborted" { say $reason; return };

    my $size = (get() // abort).chomp.Int;
    abort if $size < 2;

    my @player = <computer human>;
    my %other = @player Z=> @player.rotate;
    my @board = True xx $size;

    my $start-player = (get() // abort).chomp;
    abort unless $start-player eq any @player;
    play :$size, start => $start-player;
}

multi sub MAIN('test') {
    use Test;
    plan *;
    is next-move([True xx 5]), 0, 'empty board => 0';
    is next-move([True xx 2, False xx 2]), 0, 'only choice left (0)';
    is next-move([False xx 2, True xx 2]), 2, 'only choice left (2)';
    ok next-move([True, False, False, True]) == any(0,3), 'only choice left (circular)';

    ok next-move([True xx 2, False xx 2, True xx 2, False xx 2])
            == any(0, 4), '2 islands length 2';
    ok next-move([True xx 3, False xx 2, True xx 3, False xx 2])
            == any(0, 1, 5, 6), '2 islands length 2';

    is next-move([False xx 2, True xx 4]), 3, 'winning move with length 4 (1)';
    ok next-move([False xx 2, True xx 4, False xx 2, True xx 3])
            == any(2, 4),
            'winning move with length 4 (2)';
    is next-move([False, True xx 10, False]), 5, 'winning move for length 10';

    done;
}


# vim: ft=perl6

This version of the code carries three improvements:

The new little heuristic plugs the hole I found in the previous version. So I guess I'll have to find a new one:

15
human
human to move: 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14
6,7
computer takes 8,9
human to move: 0,1,2,3,4,5,10,11,12,13,14
10,11
computer takes 13,14
human to move: 0,1,2,3,4,5,12
2,3
computer takes 4,5
human to move: 0,1,12
0,1
human wins