• Learning UCT, not finish yet.
  • Reference: mcts.ai

  • -p, –power N power of AI
  • -s, –size N play game on board of size N
  • -e, –eliminate N maximum stones can be eliminated one time
  • -f, –first player first
  • -l, –last player who eliminates the last stone lose the game
  • -h, –help show this message

In each turn, the input may be:

  • “row1, col1, row2, col2” (eg: 0,0,2,2) which would eliminate all stones between (row1, col1) and (row2, col2) (both inclusive).
  • “row, col” which eliminate only 1 stone.
# reference: http://mcts.ai/code/python.html

require 'optparse'


# stone position
class Position
  attr_accessor :row
  attr_accessor :col

  def initialize(pos_row, pos_col)
    @row = pos_row
    @col = pos_col
  end

  def right!(distance = 1)
    @col += distance
    self
  end

  def down!(distance = 1)
    @row += distance
    self
  end

  def diagonal!(distance = 1)
    @col += distance
    @row += distance
    self
  end

  def ==(that)
    that.col == col and that.row == row
  end

  def inspect
    "pos (#{row}, #{col})"
  end
end

# A move represents all positions between head and tail.
class Move
  attr_accessor :head
  attr_accessor :tail

  def initialize(pos1, pos2 = nil)
    if pos2.nil?
      pos2 = pos1
    elsif pos2.col < pos1.col or pos2.row < pos1.row
      pos1, pos2 = pos2, pos1
    end

    @head = Position.new(pos1.row, pos1.col)
    @tail = Position.new(pos2.row, pos2.col)
  end

  # return all positions between head and tail (head and tail are included)
  def positions
    if head.row == tail.row
      (head.col..tail.col).each.map { | c | Position.new(head.row, c) }
    elsif head.col == tail.col
      (head.row..tail.row).each.map { | r | Position.new(r, head.col) }
    else
      (head.row..tail.row).each_with_index.map do | r, i |
        Position.new(r, head.col + i)
      end
    end
  end

  def ==(that)
    that.head == head and that.tail == tail
  end
end

class Board
  LIVE_STONE_SIGN = "○"
  DEAD_STONE_SIGN = "●"

  LIVE_STONE = true
  DEAD_STONE = false

  attr_reader :max_elimination
  attr_reader :stones

  # board_size: height of the triangle with circles.
  # max_elimination: max number of connected circles can be eliminated in each turn.
  def initialize(board_size = 6, max_elimination = 3)
    @max_elimination = [1, max_elimination].max

    @stones = Array.new([2, board_size].max) do | line |
      Array.new(line + 1, LIVE_STONE)
    end
  end

  # the representation of a board with height 10:
  # --------------
  #   0 ○
  #   1 ○○
  #   2 ○○○
  #   3 ○○○○
  #   4 ○○○○○
  #   5 ○○○○○○
  #   6 ○○○○○○○
  #   7 ○○○○○○○○
  #   8 ○○○○○○○○○
  #   9 ○○○○○○○○○○
  #     0123456789
  # --------------
  def inspect
    gutter_size = size.to_s.length + 2

    spliter = "-" * (gutter_size + size)

    result = spliter.clone

    stones.each_with_index do | line, i |
      result << "\n#{ i.to_s.rjust gutter_size - 1 } "

      line.each do | s |
        result << (s == LIVE_STONE ? LIVE_STONE_SIGN : DEAD_STONE_SIGN)
      end
    end

    result << "\n"
    result << " " * gutter_size

    size.times { | i | result << (i % 10).to_s }

    result << "\n#{spliter}"
  end

  # clone the board and return it
  def copy
    board = Board.new(size, max_elimination)

    dead_stones.each { | pos | board.dead_stone!(pos) }

    board
  end

  def size
    stones.length
  end

  def stone(pos)
    stones[pos.row][pos.col]
  end

  def filtered_stones(filter)
    result = []

    stones.each_with_index do | line, row |
      line.each_with_index do | s, col |
        if filter.call(row, col, s)
          result << Position.new(row, col)
        end
      end
    end

    result
  end

  def valid_stone?(pos)
    pos.row < size and pos.col <= pos.row
  end

  def live_stone?(pos)
    valid_stone?(pos) and stone(pos) == LIVE_STONE
  end

  def dead_stone?(pos)
    valid_stone?(pos) and stone(pos) == DEAD_STONE
  end

  def dead_stone!(pos)
    @stones[pos.row][pos.col] = DEAD_STONE
  end

  # => return positions of all live stones
  def live_stones
    filtered_stones(lambda { | row, col, live | live == LIVE_STONE })
  end

  # => return positions of all dead stones
  def dead_stones
    filtered_stones(lambda { | row, col, live | live == DEAD_STONE })
  end

  # max length that all stones between (row, col) and (row, col + length - 1)
  # are alive.
  def max_row_length_from(pos)
    (0...max_elimination).each do | l |
      return l unless live_stone?(Position.new(pos.row, pos.col + l))
    end

    max_elimination
  end

  # max length that all stones between (row, col) and (row + length - 1, col)
  # are alive.
  def max_col_length_from(pos)
    (0...max_elimination).each do | l |
      return l unless live_stone?(Position.new(pos.row + l, pos.col))
    end

    max_elimination
  end

  # max length that all stones between (row, col) and
  # (row + length - 1, col + length - 1) are alive.
  def max_dia_length_from(pos)
    (0...max_elimination).each do | l |
      return l unless live_stone?(Position.new(pos.row + l, pos.col + l))
    end

    max_elimination
  end

  # eliminate stones between (row, col) and (row, col + l)
  # l is a random length less than max_row_length_from(row, col)
  def random_row_move_from(pos)
    length = rand(max_row_length_from(pos))

    Move.new(pos, Position.new(pos.row, pos.col + length))
  end

  # eliminate stones between (row, col) and (row + l, col)
  # l is a random length less than max_col_length_from(row, col)
  def random_col_move_from(pos)
    length = rand(max_col_length_from(pos))

    Move.new(pos, Position.new(pos.row + length, pos.col))
  end

  # eliminate stones between (row, col) and (row + l, col + l)
  # l is a random length less than max_dia_length_from(row, col)
  def random_dia_move_from(pos)
    length = rand(max_dia_length_from(pos))

    Move.new(pos, Position.new(pos.row + length, pos.col + length))
  end

  def random_live_stone
    live_stones.sample
  end

  def random_eliminate
    head = random_live_stone

    return nil if head.nil?

    move = case rand(3)
    when 0
      random_row_move_from(head)
    when 1
      random_col_move_from(head)
    else
      random_dia_move_from(head)
    end

    eliminate move

    move
  end

  def eliminate(move)
    move.positions.each do | pos |
      dead_stone! pos
    end
  end

  # all possible moves from current states
  def moves
    all = []

    live_stones.each do | head |
      len = max_row_length_from(head)

      (0...len).each do | l |
        all << Move.new(head, Position.new(head.row, head.col + l))
      end

      len = max_col_length_from(head)

      (0...len).each do | l |
        all << Move.new(head, Position.new(head.row + l, head.col))
      end

      len = max_dia_length_from(head)

      (0...len).each do | l |
        all << Move.new(head, Position.new(head.row + l, head.col + l))
      end
    end

    all
  end
end

class Node
  PLAYER_BOT = 0
  PLAYER_USR = 1

  attr_reader :move
  attr_reader :possible_moves
  attr_reader :children
  attr_reader :parent
  attr_reader :player

  attr_accessor :count_of_wins
  attr_accessor :count_of_visits

  def initialize(current_move, parent, current_player, board)
    @move = current_move
    @parent = parent
    @children = []
    @count_of_wins = 0
    @count_of_visits = 0
    @player = current_player
    @possible_moves = board.moves
  end

  def select
    @children.max do | a, b |
      x = 2 * Math.log(count_of_visits) / a.count_of_visits
      y = 2 * Math.log(count_of_visits) / b.count_of_visits

      x = a.count_of_wins / a.count_of_visits + Math.sqrt(x)
      y = b.count_of_wins / b.count_of_visits + Math.sqrt(y)

      x <=> y
    end
  end

  def add_child(move, board)
    @possible_moves.delete(move)

    @children << Node.new(move, self, match, board)

    @children[-1]
  end

  def update(winner)
    @count_of_visits += 1
    @count_of_wins += if winner == player then 1 else -1 end
  end

  def match
    1 - player
  end
end

class Game
  MODE_LAST_WIN = 0
  MODE_LAST_LOSE = 1

  STATE_NOT_ENDED = 0
  STATE_USR_WIN = 1
  STATE_BOT_WIN = 2

  def self.parse_args
    arguments = {}

    arguments[:size] = 6
    arguments[:max_elimination] = 3
    arguments[:last_win] = true
    arguments[:usr_first] = false
    arguments[:power] = 1

    option_parser = OptionParser.new do | options |
      options.banner = "Usage: circles.rb [options]"

      options.separator ""
      options.separator "Specific options:"

      options.on("-p N", "--power N", Integer, "power of AI") do | s |
        arguments[:power] = s
      end

      options.on("-s N", "--size N", Integer, "play game on board of size N") do | s |
        arguments[:size] = s
      end

      options.on("-e N", "--eliminate N", Integer,
        "maximum stones can be eliminated one time") do | e |
        arguments[:max_elimination] = e
      end

      options.on("-f", "--first", "player first") do |v|
        arguments[:usr_first] = true
      end

      options.on("-l", "--last",
        "player who eliminates the last stone lost the game") do |v|
        arguments[:last_win] = false
      end

      options.on_tail("-h", "--help", "show this message") do
        puts options
        exit
      end

      # options.on_tail("-t", "--test", "run test") do
      #   Test.test
      #   exit
      # end
    end

    option_parser.parse!(ARGV)

    arguments
  end

  def initialize(arguments = nil)
    if arguments.nil?
      arguments = {
        size: 6,
        max_elimination: 3,
        last_win: true,
        usr_first: false,
        power: 1
      }
    end

    @board = Board.new(arguments[:size], arguments[:max_elimination])
    @mode = arguments[:last_win] ? MODE_LAST_WIN : MODE_LAST_LOSE
    @bot_first = !arguments[:usr_first]
    @state = STATE_NOT_ENDED
    @number_of_trials = arguments[:power] * 200
  end

  def ended?
    @state != STATE_NOT_ENDED
  end

  def bot_eliminate
    return false if ended?

    root = Node.new(nil, nil, Node::PLAYER_USR, @board)

    @number_of_trials.times do
      node = root

      dup_board = @board.copy

      # select
      while node.possible_moves.empty? and node.children.length > 0 do
        node = node.select

        dup_board.eliminate node.move
      end

      # Expand
      unless node.possible_moves.empty?
        move = node.possible_moves.sample

        dup_board.eliminate move

        node = node.add_child(move, dup_board)
      end

      # Rollout
      count = 0

      until dup_board.random_eliminate.nil? do
        count += 1
      end

      winner = if @mode = MODE_LAST_WIN
        count.odd? ? 1 - node.player : node.player
      else
        count.odd? ? node.player : 1 - node.player
      end

      # Backpropagate
      until node.nil? do
        node.update winner
        node = node.parent
      end
    end

    node = root.children.max { | a, b | a.count_of_visits <=> b.count_of_visits }

    @board.eliminate node.move

    if @board.live_stones.empty?
      @state = (@mode == MODE_LAST_WIN) ? STATE_BOT_WIN : STATE_USR_WIN
    end

    true
  end

  def usr_eliminate(row1, col1, row2, col2)
    return false if ended?

    move = Move.new(Position.new(row1, col1), Position.new(row2, col2))

    return false unless @board.eliminate(move)

    if @board.live_stones.empty?
      @state = (@mode == MODE_LAST_WIN) ? STATE_USR_WIN : STATE_BOT_WIN
    end

    true
  end

  def play
    turn = 0

    unless @bot_first
      turn = 1

      p @board
    end

    loop do
      if turn.even?
        bot_eliminate
      else
        target = STDIN.gets.chomp

        positions = target.split(",").map { | s | s.to_i }

        if positions.length >= 4
          usr_eliminate(positions[0], positions[1], positions[2], positions[3])
        else
          usr_eliminate(positions[0], positions[1], positions[0], positions[1])
        end
      end

      p @board

      break if ended?

      turn += 1
    end

    p "You #{@state == STATE_BOT_WIN ? "LOSE" : "WIN"}"
  end
end

Game.new(Game.parse_args).play if __FILE__ == $0