[SOLUTION] Chess Variants (I) (#35)

Here is my solution. It is not fully designed to support variants and
I don't know if I will have time to adapt it (it seems to me a highly
nontrivial task).

Best regards,

Paolo Capriotti

···

---

class Numeric
  def sign
    if self > 0
      1
    else
      if self < 0 then -1 else 0 end
    end
  end
end

class Piece
  attr_reader :type, :color
  def initialize(color, type)
    @type = type
    @color = color
  end
  
  def type_initial
    if @type == :knight
      'n'
    else
      @type.to_s[0,1]
    end
  end
  
  def ==(other)
    return false unless other.respond_to?(:color)
    (@type == other.type and @color == other.color)
  end
end

class Vector
  attr_reader :x, :y
  def initialize(x,y)
    @x,@y = x,y
  end
  
  def +(other)
    x = @x + other[0]
    y = @y + other[1]
    Vector.new(x,y)
  end
  
  def -(other)
    x = @x - other[0]
    y = @y - other[1]
    Vector.new(x,y)
  end
  
  def *(number)
    x = @x * number
    y = @y * number
    Vector.new(x,y)
  end
  
  def ==(other)
    return false unless other.respond_to?(:[])
    @x == other[0] and @y == other[1]
  end
  
  def [](i)
    case i
    when 0
      @x
    when 1
      @y
    end
  end
  
  def clone
    Vector.new(@x,@y)
  end
end

class Board
  attr_reader :size, :turn
  attr_writer :promotion_piece
  def initialize(size_x, size_y)
    @size = Vector.new(size_x, size_y)
    @board = []
    (0...size_x).each do |x|
      @board[x] = []
    end
    @turn = :white
    @king_moved = Hash.new(false)
    @queen_rook_moved = Hash.new(false)
    @king_rook_moved = Hash.new(false)
  end
  
  def [](*position)
    position = get_pos(position)
    @board[position.x][position.y]
  end
  
  def []=(*args)
    value = args.pop
    position = get_pos(args)
    @board[position.x][position.y] = value
    self
  end
  
  def move(from, to)
    piece = self[from]
    execute_move(from, to)
    
    case from
    when king_starting_position
      @king_moved[@turn] = true
    when king_rook_starting_position
      @king_rook_moved[@turn] = true
    when queen_rook_starting_position
      @queen_rook_moved[@turn] = true
    end
    
    if castling(from, to)
      if to.x - from.x > 0
        self[from+[1,0]] = self[king_rook_starting_position]
        self[king_rook_starting_position] = nil
      else
        self[from+[-1,0]] = self[queen_rook_starting_position]
        self[queen_rook_starting_position] = nil
      end
    end
    
    if to == @en_passant
      self[to-[0,pawn_dir]] = nil
    end
    
    if piece.type == :pawn and (to.y - from.y) == 2
      @en_passant = to-[0,pawn_dir]
    else
      @en_passant = nil
    end
    
    switch_turn
  end
  
  def pseudolegal_move(from, to)
    return false if from == to
    piece = self[from]
    return false unless piece
    return false unless piece.color == turn
    return false if self[to] and self[to].color == piece.color
    
    d = to - from
    case piece.type
    when :king
      if (d.x.abs <= 1 and d.y.abs <= 1)
        return true
      end
      if castling(from, to)
        return false if @king_moved[@turn]
        if d.x > 0 # king side
          return false if @king_rook_moved[@turn]
          return false unless (self[from + [1,0]] == nil or self[from
+ [2,0]] == nil)
        else
          return false if @queen_rook_moved[@turn]
          return false unless (from + [-1,0] == nil or from + [-2,0]
== nil or from + [-3,0] == nil)
        end
        return true
      end
    when :queen
      return ((rook_slide(d) or bishop_slide(d)) and check_free_path(from, to))
    when :rook
      return (rook_slide(d) and check_free_path(from, to))
    when :bishop
      return (bishop_slide(d) and check_free_path(from, to))
    when :knight
      return knight_jump(d)
    when :pawn
      case d.x.abs
      when 1
        return (d.y == pawn_dir and ((self[to] and (not self[to].color
== piece.color)) or to == @en_passant))
      when 0
        case d.y
        when pawn_dir
          return self[to] == nil
        when pawn_dir*2
          return (from.y == rank(2) and self[from+[0,pawn_dir]] == nil
and self[to] == nil)
        else
          return false
        end
      else
        return false
      end
    end
  end
  
  def legal_move(from, to)
    return false unless pseudolegal_move(from, to)
    
    old_turn = @turn
    switch_turn
    res = check_legality(from, to, old_turn)
    @turn = old_turn

    return res
  end
  
  def is_valid(*args)
    position = get_pos(args)
    position.x >= 0 and position.x < size.x and position.y >= 0 and
position.y < size.y
  end
  
  def find_piece(piece)
    each_piece do |position, p|
      return position if piece == p
    end
    return nil
  end
  
  def each_piece
    (0...size.x).each do |x|
      (0...size.y).each do |y|
        pos = Vector.new(x,y)
        piece = self[pos]
        yield(Vector.new(x,y), self[x,y]) if self[x,y]
      end
    end
  end

  def show
    (0...@size.y).each do |y|
      (0...@size.x).each do |x|
        piece = self[x,y]
        if piece
          s = piece.type_initial
          s.upcase! if (piece.color == :white)
          print s, ' '
        else
          print ' '
        end
      end
      print "\n"
    end
    print "\n"
  end

  def promotion(from, to)
    to.y == rank(8) and self[from].type == :pawn
  end
  
  def capturing(from, to)
    return true if self[to]
    self[from].type == :pawn and (not from.x == to.x)
  end
  
  def game_state
    generator = MoveGenerator.new(self)
    if generator.unstalled
      return :in_game
    else
      old_turn = @turn
      switch_turn
      if leaving_king_safe(old_turn)
        res = :stalemate
      else
        res = (@turn == :white ? :white_wins : :black_wins)
      end
      
      return res
    end
  end
  
  def pawn_dir
    @turn == :white ? -1 : 1
  end
  
  def rank(r)
    @turn == :white ? 8 - r : r - 1
  end
  
  def possible_starting_points(piece_type, to, capt)
    generator = MoveGenerator.new(self) do |from, dest|
      if to == dest and self[from].type == piece_type and
pseudolegal_move(from, dest) and capturing(from, to) == capt
        from
      end
    end
    generator.generate_all
  end
  
  def king_starting_position
    Vector.new(4, rank(1))
  end

  def king_rook_starting_position
    Vector.new(7, rank(1))
  end
  
  def queen_rook_starting_position
    Vector.new(0, rank(1))
  end
  
private
  def check_legality(from, to, old_turn)
    if castling(from, to)
      castling_directions(from, to)+[[0,0]].each do |direction|
        return false unless leaving_position_safe(from+direction)
      end
    end
    
    old_board = []
    (0...size.x).each do |x|
      old_board[x] = @board[x].clone
    end
    
    execute_move(from, to)
    res = leaving_king_safe(old_turn)
    @board = old_board
    res
  end
  
  def check_free_path(from, to)
    d = to - from
    inc = [d.x.sign, d.y.sign]
    pos = from.clone
    while not pos == to - inc
      pos += inc
      return false if self[pos]
    end
    return true
  end

  def castling(from, to)
    from == king_starting_position and (from-to).x.abs == 2
  end
  
  def rook_slide(d)
    d.x == 0 or d.y == 0
  end
  
  def bishop_slide(d)
    d.x.abs == d.y.abs
  end
  
  def knight_jump(d)
    (d.x.abs == 2 and d.y.abs == 1) or (d.x.abs == 1 and d.y.abs == 2)
  end
  
  def switch_turn
    @turn = (@turn == :white ? :black : :white)
  end
    
  def leaving_king_safe(old_turn)
    king_pos = find_piece(Piece.new(old_turn, :king))
    leaving_position_safe(king_pos)
  end
  
  def castling_directions(from, to)
    if (to.x > from.x)
      return [[1,0]]
    else
      return [[-1,0],[-2,0]]
    end
  end
  
  def leaving_position_safe(safepos)
    each_piece do |position, piece|
      if piece.color == @turn
        return false if pseudolegal_move(position, safepos)
      end
    end
  end
  
  def get_pos(v)
    case v.size
    when 1
      v[0]
    when 2
      Vector.new(v[0], v[1])
    end
  end
  
  def execute_move(from, to)
    if promotion(from, to)
      self[to] = Piece.new(@turn, @promotion_piece)
    else
      self[to] = self[from]
    end

    self[from] = nil
  end
end

class MoveGenerator
  def initialize(board, &valid)
    @board = board
    if block_given?
      @valid_move = valid
    else
      @valid_move = lambda do |from, to|
        if @board.is_valid(to) and @board.legal_move(from, to)
          return to
        end
      end
    end
  end
  
  def unstalled
    @fast = true
    found = catch(:move_found) do
      @board.each_piece do |position, piece|
        if piece.color == @board.turn
          can_move(position)
        end
      end
    end
    found == true
  end
  
  def generate_all
    @fast = false
    move_list = []
    @board.each_piece do |position, piece|
      if piece.color == @board.turn
        move_list += can_move(position)
      end
    end
    move_list
  end
  
  def can_move(from)
    piece = @board[from]
    move_list = []
    return [] unless piece
    
    case piece.type
    when :king
      generate_directions.each do |direction|
        move = got_move(from, from + direction)
        move_list << move if move
      end
    when :queen
      generate_directions.each do |direction|
        move_list += generate_slide(from, direction)
      end
    when :rook
      [[1,0],[-1,0],[0,1],[0,-1]].each do |direction|
        move_list += generate_slide(from, direction)
      end
    when :bishop
      [[1,1],[-1,1],[1,-1],[-1,-1]].each do |direction|
        move_list += generate_slide(from, direction)
      end
    when :knight
      [[2,1],[2,-1],[-2,1],[-2,-1],[1,2],[1,-2],[-1,2],[-1,-2]].each do |jump|
        move = got_move(from, from + jump)
        move_list << move if move
      end
    when :pawn
      [[0,@board.pawn_dir],[0,@board.pawn_dir*2],[1,@board.pawn_dir],[-1,@board.pawn_dir]].each
do |jump|
        move = got_move(from, from + jump)
        move_list << move if move
      end
    end
    
    return move_list
  end
  
private
  
  def generate_slide(from, direction)
    move_list = []
    pos = from
    while @board.is_valid(pos += direction) do
      move = got_move(from, pos)
      move_list << move if move
    end
    move_list
  end
  
  def generate_directions
    dirs = []
    (-1..1).each do |x|
      (-1..1).each do |y|
        dirs << [x,y] unless x == 0 and y == 0
      end
    end
    return dirs
  end
  
  def got_move(from, to)
    if move = @valid_move[from, to]
      throw(:move_found, true) if @fast
      move
    else
      false
    end
  end
  
end

module UI
  def ask_move
    @promotion_piece = nil
    loop do
      print ": "
      input = gets
      raise "no move" unless input
      case input.chomp
      when /^\s*$/
        return nil
      when /^(\d)(\d)\s*(\d)(\d)$/
        return Vector.new($1.to_i, $2.to_i), Vector.new($3.to_i, $4.to_i)
      when /^([RNBQK]?)([a-h1-8]?)(x?)([a-h])([1-8])(=[RNBQK])?[#+]?$/
        piece_type = letter2piece($1)
        prom = $6
        to = Vector.new($4[0]-'a'[0], 8 - $5.to_i)
        sp = @board.possible_starting_points(piece_type, to, $3 == 'x')
        unless $2.nil? or $2 == ""
          letter = $2
          if letter =~ /[a-h]/
            check = lambda do |possible_from|
              possible_from.x == letter[0] - 'a'[0]
            end
          else
            check = lambda do |possible_from|
              possible_from.y == 8 - letter.to_i
            end
          end
          
          sp.reject! do |possible_from|
            not check[possible_from]
          end
        end
        
        case sp.size
        when 0
          say "incorrect notation"
        when 1
          from = sp.first
        else
          say "ambiguous notation"
        end

        if from
          if piece_type == :pawn and to.y == @board.rank(8)
            @promotion_piece = letter2piece($5[1,1])
          end
          
          if from
            return from, to
          end
          
        end
      when "O-O"
          pos = @board.king_starting_position
          return pos, pos + [2,0]
      when "O-O-O"
          pos = @board.queen_starting_position
          return pos, pos - [2,0]
      end
      
    end
  end
  
  def letter2piece(letter)
    case letter
    when 'R'
      return :rook
    when 'N'
      return :knight
    when 'B'
      return :bishop
    when 'Q'
      return :queen
    when 'K'
      return :king
    else
      return :pawn
    end
  end
  
  def ask_promotion_piece
    return @promotion_piece if @promotion_piece
    print "promote to (default: queen): "
    case gets.chomp!
    when "rook" || 'r'
      return :rook
    when "knight" || 'n'
      return :knight
    when "bishop" || 'b'
      return :bishop
    else
      return :queen || 'q'
    end
  end
  
  def say(msg)
    puts msg.to_s.gsub(/_/) { ' ' }
  end
  
  def show_board
    say "turn : #{@board.turn.to_s}"
    (0...@board.size.y).each do |y|
      (0...@board.size.x).each do |x|
        piece = @board[x,y]
        if piece
          s = piece.type_initial
          s.upcase! if (piece.color == :white)
          print s, ' '
        else
          print ' '
        end
      end
      print "\n"
    end
    print "\n"
  end

end

class ChessGame
  attr_reader :board
  include UI
  
  def initialize
    @board = Board.new(8,8)
    @board.promotion_piece = :queen
    
    (0...8).each do |x|
      @board[x,1] = Piece.new( :black, :pawn )
      @board[x,6] = Piece.new( :white, :pawn )
    end
    
    @board[0,0] = Piece.new( :black, :rook )
    @board[1,0] = Piece.new( :black, :knight )
    @board[2,0] = Piece.new( :black, :bishop )
    @board[3,0] = Piece.new( :black, :queen )
    @board[4,0] = Piece.new( :black, :king )
    @board[5,0] = Piece.new( :black, :bishop )
    @board[6,0] = Piece.new( :black, :knight )
    @board[7,0] = Piece.new( :black, :rook )
    
    @board[0,7] = Piece.new( :white, :rook )
    @board[1,7] = Piece.new( :white, :knight )
    @board[2,7] = Piece.new( :white, :bishop )
    @board[3,7] = Piece.new( :white, :queen )
    @board[4,7] = Piece.new( :white, :king )
    @board[5,7] = Piece.new( :white, :bishop )
    @board[6,7] = Piece.new( :white, :knight )
    @board[7,7] = Piece.new( :white, :rook )
  end
  
  def play
    while (state = @board.game_state) == :in_game
      begin
        move
      rescue RuntimeError => err
        print "\n"
        if err.message == "no move"
          say :exiting
        else
          say err.message
        end
        return
      end
    end
    show_board
    say state
  end
  
  def move
    loop do
      say ""
      show_board
      from, to = ask_move
      raise "no move" unless from
      if @board.is_valid(from) and @board.is_valid(to) and
@board.legal_move(from, to)
        if @board.promotion(from, to)
          @board.promotion_piece = ask_promotion_piece
        end
        @board.move(from, to)
        break
      else
        say :invalid_move
      end
    end
  end
end

@game = ChessGame.new
@game.play

My solution can be found at http://phrogz.net/RubyLibs/GKChess.rb

My solution does not allow castling or en-passant. (Castling would be far easier if you didn't have to determine if any other piece challenged the intermediary space :stuck_out_tongue: En-passant is annoying because of the turn-based restriction.) It also does not check for checkmate.

Things I'm proud of:
* Subclassing Piece for each type to delegate the possible moves calculation.

* Some of the DRY stuff in calculating possible moves along various axes for everyone but the Pawn

* Some nice robustness and messages when you try to pick an invalid piece, or send a piece somewhere it can't go. (After you pick the piece to move, if you give it an invalid destination it tells you what moves are legal.)

* Subclassing RuntimeError for the first time only and using it to my advantage. (OutOfBoundsError and IllegalMoveError).

I'm actually not sure if I should be proud of the last one...is it 'good' to run code you know will throw an error and then catch that as a way of detecting bounds, versus calculating the bounds properly?

When calculating the moves that a rook could do, for example, I moved along a particular axis until I found another piece, adding each square to the possible moves. However, if no pieces existed between me and the edge of the board, I let the algorithm charge off the edge of the board, waiting for the Move constructor to throw an OutOfBoundsError as another way to let me know that it was time to stop.

Things I'm not so proud of:
* Sometimes I use 'a2' to access information, sometimes ['a','2']; consequently a lot of my methods have code like:
   def foo( col, row=nil )
       col, row = (col.to_s+row.to_s).split('')
   so that I can pass the information around as separate arguments, one string, or an array. Tricky, but hacky.

* The pieces know what they are allowed to do on the board...but don't know anything about check or checkmate. That's up to the Board. Consequently, pieces think they can make a move that would result in self-check, and list those choices among the options. The error message when you try to make a move that would put your own king in check is not helpful.

* I use #deep_clone with the Marshal hack to set up a scenario to detect check without altering the current board. This feels wrong...the board should be able to try out a move and then undo it without having to clone itself. (I was concerned about putting pieces back on the board afterwards.) Further, when I make the clone, I can't the real #move method to try out the change, because that method has all sorts of warnings that I'd have to hide. Ugly.

The trickiest part for me (that I ended up not solving) is figuring out where the logic of the game goes. Shouldn't each piece know what it can do? But then it needs to know about the entire board. And (for en-passant) the recent history of moves for each piece. Ugly.

Thanks for a challenging puzzle. Having refactored some pieces twice, it was a very useful exercise in thinking about roles and responsibilities, and my own need to plan a little more before coding.

I've found a bug. Here you can find the new version:
http://linuz.sns.it/~capriotti/ruby/rchess.rb

Paolo Capriotti

···

On 6/15/05, Paolo Capriotti <p.capriotti@gmail.com> wrote:

Here is my solution.

My own can be found at:

http://rubyquiz.com/ruby_quiz_chess.zip

I tried to go the extra mile with tests and documentation to make it very approachable, in case others would like to use it to try part II of this quiz...

James Edward Gray II

···

On Jun 15, 2005, at 7:43 AM, Paolo Capriotti wrote:

Here is my solution.