Friday, February 6, 2009

Trying out ruby for the very first time

So, I've made up my mind. The language of this year is going to be Ruby! The reasons are many, but mostly I feel I can use this language more in my daily work, and on a podcast over at AltDotNet about Ruby, they mentioned something like "learning ruby will make you a better C# developer". Languages like lisp and python will have to wait for at least one year!

About two weeks ago, I was out skiing with Gustaf and some friends of him. We ended up playing a lot of four in a row, a really funny and simple game. This game made me think about how to develop a min max AI, and suddenly I had written down the pseudo code for such an AI in my notebook. Here is a picture of the game is supposed to be played (from wikipedia):
Now, back home at my computer, I started digging into the ruby documentations, and after not more than a few hours I had finished the first version of the AI! Turns out Ruby isn't that hard to learn, at least not if you just want to get some basic things done. It's like writing c#, except you skip the types, and instead of writing void foo(int x) { ... } you write def foo(x) ... end, and so on...

Here is how my final version of the game looks when you run it:

C:\Documents and Settings\Christian\Desktop>ruby 4irad.rb
Run against ai (A), another player (P) or let two AIs play against eachother (X)
A
Enter AI 1 level (1 to 5):
3
Enter name of human player (default Human 1):

---------------
|0 1 2 3 4 5 6|
---------------
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
---------------
AI 1 (3) played: 3
---------------
|0 1 2 3 4 5 6|
---------------
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | |O| | | |
---------------
Human 1, make your move:
2
Human 1 played: 2
---------------
|0 1 2 3 4 5 6|
---------------
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | |X|O| | | |
---------------
AI 1 (3) played: 3
---------------
|0 1 2 3 4 5 6|
---------------
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | |O| | | |
| | |X|O| | | |
---------------
Human 1, make your move:

and so on...

I wonder how much code can you post in a blog, without the post becoming too long? I would like to post the code as an attachment, but I'm not sure you can do that in blogger, so I'll simply put it here. To try it out, copy the code to a file, i.e. 4inarow.rb, then run it using ruby 4inarow.rb. Enjoy ;)
$debugEnabled = false
def debug(text)
 if $debugEnabled then
  puts ">>> " + text
 end
end

class State

 attr_reader :cols, :rows, :player, :winner, :lastPlayed, :isFull
 
 Player1 = "Player 1"
 Player2 = "Player 2"

 def initialize
  @cols = []
  (0..6).each { @cols << [] }
  @rows = []
  (0..5).each { @rows << Array.new(7, nil) }
  @player = Player1
  @winner = nil
  @isFull = false
 end
 
 def deep_copy
  return Marshal.load( Marshal.dump( self ) )
 end
 
 def to_s
  return "---------------\n" + 
   "|" + (0..6).to_a.join(" ") + "|\n" +
   "---------------\n" +
   @rows.reverse.collect{|row| "|" + row.collect{|r| if !r then " " elsif r == Player2 then "X" else "O" end}.join("|") + "|"}.join("\n") + "\n" +
   "---------------"
 end

 def canPlay( i )
  return @winner == nil && @cols[i].length < 6
 end

 def play( i )
  if canPlay i then
   rowIndex = @cols[i].length
   @cols[i] << @player
   @rows[rowIndex][i] = @player
   @lastPlayed = i

   switchPlayer
   updateWinner
   updateIsFull
  end
 end
 
 def updateIsFull
  for p in @rows[5]
   if !p then return end
  end
  @isFull = true
 end

 def findWinner( row )
  for i in 0..(row.length-4)
   p = nil
   n = 0
   for j in 0..3
    rp = getPlayed(row[i+j])
    if rp.class != Fixnum then
     if !p then
      p = rp
     elsif p != rp
      p = 0
      break
     end
     if rp != 0 then
      n += 1
     end
    end
   end
   if p != 0 and n == 4 then
    return p
   end
  end
  return nil
 end

 def updateWinner
  for row in getAllRanges
   p = findWinner row
   if p then
    @winner = p
   end
  end
 end

 def switchPlayer
  if @player == Player1 then
   @player = Player2
  else
   @player = Player1
  end
 end

 def getPlayed(cell)
  c = cell[0]
  r = cell[1]
  col = @cols[c]
  if r >= col.length then
   return r - col.length + 1
  end
  return @cols[c][r]
 end

 def posValid( c, r )
  return c >= 0 && c <= 6 && r >= 0 && r <= 5
 end

 def getDiagonal( c, r, dc )
  d = []
  while posValid( c, r )
   d << [c, r] 
   c += dc
   r += 1
  end
  return d
 end

 def getAllDiagonalsRanges
  diags = []
  for r in 0..5
   diags << getDiagonal( 0, r, 1 )
  end
  for c in 1..6
   diags << getDiagonal( c, 0, 1 )
  end
  for r in 0..5
   diags << getDiagonal( 6, r, -1 )
  end
  for c in 0..5
   diags << getDiagonal( c, 0, -1 )
  end
  return diags
 end
 
 def getAllColsRanges
  return (0..6).collect{|c| (0..5).collect{|r| [c, r]}}
 end
 def getAllRowsRanges
  return (0..5).collect{|r| (0..6).collect{|c| [c, r]}}
 end
 
 def getAllRanges
  return  getAllColsRanges + 
    getAllRowsRanges + 
    getAllDiagonalsRanges
 end
end

class Node

 attr_reader :state, :children, :isLeaf

 def initialize( state, player )
  if !player then
   raise "player is nil"
  end
  
  @state = state
  @isLeaf = true
  @children = {}
  @player = player
 end
 
 def getPoints( row )
  points = 0.0
  for i in 0..(row.length-4)
   p = nil
   n = 0
   divider = 1
   for j in 0..3
    rp = @state.getPlayed(row[i+j])
    if rp.class == Fixnum then
     divider *= rp
    else
     if !p then
      p = rp
     elsif !rp then
     
     elsif p != rp
      p = nil
      break
     end
     if rp then
      n += 1
     end
    end
   end
   if p then
    blockPoints = calculatePoints n / divider
    if p == @player then
     points += blockPoints
    else
     points = points - blockPoints
    end
   end
  end
  if points.infinite? then
   debug "Found winner: " + points.to_s + ": " + row.join("|")
  end
  return points
 end

 def calculatePoints( n )
  return n.to_f**3 / (4 - [n,4].min)
 end

 def nodePoints
  points = 0.0
  for row in state.getAllRanges
   p = getPoints(row)
   if p != 0 then
    #debug row.collect{|r| if r then r else " " end}.join("|") + " " + p.to_s
   end
   
   newPoints = points + p
   if newPoints.nan? then
    raise "points + p == NaN: points=" + points.to_s + " p=" + p.to_s
   end
   points = newPoints
  end
  #puts state
  #puts "Points: " + points.to_s
  return points
 end

 def allChildPoints
  return children.values.collect {|child| child.totalPoints}
 end

 def isMyTurn
  return state.player == @player
 end

 def totalPoints
  if @totalPoints then
   return @totalPoints
  end
  p = nil
  if @isLeaf then
   p = nodePoints
  else
   if isMyTurn then
    p = allChildPoints.max
   else
    p = allChildPoints.min
   end
  end
  if !p then
   raise "nil points returned"
  end
  @totalPoints = p
  return p
 end

 def grow
  if @totalPoints then
   #puts "Gah, totalPoints is set"
   @totalPoints = nil
  end
  if @isLeaf && !@state.winner
   #debug "Growing leaf: " + self.to_s
   for i in 0..6
    if @state.canPlay i then
     childState = @state.deep_copy
     childState.play i
     child = Node.new(childState, @player)
     children[i] = child
    end
   end
   @isLeaf = false
  else
   for child in @children.values
    child.grow
   end
  end
 end

 def findNodeWithState( state )
  if state.lastPlayed then
   return @children[state.lastPlayed]
  else
   return nil
  end
 end
 
 def to_s
  return "Played: " + @state.lastPlayed.to_s + " Points: " + totalPoints.to_s
 end
end

class AIPlayer

 def initialize( level, name )
  @level = level
  @name = name
 end

 def to_s
  return @name + " (" + @level.to_s + ")"
 end

 def selectBestNode
  # Add some randomness
  bestChild = nil
  bestChildCount = nil
  for child in @node.children.values
   #puts child.state
   #puts child.totalPoints
   if !bestChild then
    bestChild = child
    bestChildCount = 1
   else
    if child.totalPoints > bestChild.totalPoints then
     bestChild = child
     bestChildCount = 1
    elsif child.totalPoints == bestChild.totalPoints then
     bestChildCount = bestChildCount + 1
     if rand(bestChildCount) == 0 then
      bestChild = child
     end
    end
   end
  end
  
  #Just to make sure
  if !bestChild then
   raise "bestChild is nil"
  end
  
  debug "Best points: " + bestChild.totalPoints.to_s
  
  
  @node = bestChild
 end

 def play(state)
  if state.lastPlayed then
   debug "Finding last played node"
   @node = @node.findNodeWithState(state)
   @node.grow
  end
  debug "Finding the best node"
  selectBestNode
  @node.grow
  state.play @node.state.lastPlayed
 end
 
 def setupGame(state, player)
  @node = Node.new(state, player)
  debug "Creating min max tree (" + (7**@level).to_s + " nodes)..."
  (1..@level).each{ @node.grow } 
 end

end

class HumanPlayer

 def initialize(name)
  @name = name
 end

 def to_s
  return @name
 end

 def play(state)
  puts @name + ", make your move:"
  
  begin
   toPlay = gets
   if toPlay == "q\n" then
    exit
   end
   toPlay = toPlay.to_i
   if !state.canPlay toPlay
    puts "Can't play " + toPlay.to_s + ". Select another:"
    selectOther = true
   else
    state.play toPlay
   end
  end while selectOther
 end
 
 def setupGame(state, player)
  
 end
end

def runGame

 puts "Run against ai (A), another player (P) or let two AIs play against eachother (X)?"
 gameType = readline.strip
 case gameType
  when "A", "a"
   player1 = createAIPlayer
   player2 = createHumanPlayer
   
  when "P", "p"
   player1 = createHumanPlayer
   player2 = createHumanPlayer
   
  when "X", "x"
   player1 = createAIPlayer
   player2 = createAIPlayer
   
  else
   puts "Invalid input: #{gameType}"
   exit
 end

 players = [player1, player2]
 state = State.new
 
 player1.setupGame(state, State::Player1)
 player2.setupGame(state, State::Player2)

 while (!state.winner && !state.isFull)
  for player in players
   puts state
   player.play(state)
   puts player.to_s + " played: " + state.lastPlayed.to_s
   if state.winner then break end
  end
 end

 if state.winner
  puts state
  puts "Winner: " + state.winner.to_s
 elsif state.isFull
  puts state
  puts "Draw!"
 end
end

$aiCounter = 0
def createAIPlayer
 $aiCounter = $aiCounter + 1
 name = "AI " + $aiCounter.to_s
 puts "Enter " + name + " level (1 to 5):"
 level = readline.to_i
 return AIPlayer.new(level, name)
end

$humanCounter = 0
def createHumanPlayer
 $humanCounter = $humanCounter + 1
 defaultName = "Human " + $humanCounter.to_s
 puts "Enter name of human player (default " + defaultName + "):"
 name = readline.strip
 if !name || name == "" then
  name = defaultName
 end
 return HumanPlayer.new(name)
end

def runTests

 state = State.new
 
 state.play(3)
 state.play(3)
 state.play(4)
 state.play(4)
 state.play(5)
 state.play(5)
 state.play(6)

 #test getPlayed
 if state.getPlayed([3, 0]) != State::Player1 then raise "getPlayed doesn't work" end
 if state.getPlayed([3, 1]) != State::Player2 then raise "getPlayed doesn't work" end
 if state.getPlayed([3, 2]) != 1 then raise "getPlayed doesn't work" end
 if state.getPlayed([3, 2]).class != Fixnum then raise "getPlayed doesn't work" end

 #test findWinner
 winningRow = (3..6).collect{|c| [c, 0]}
 if !state.findWinner(winningRow) then raise "findWinner doesn't work. winningRow=" + winningRow.to_s end
 
 #test state.winner
 if state.winner == nil then raise "winner should be set" end
  
 puts "All tests ok"

end

runGame
#runTests

No comments: