bin/xo

Summary

Maintainability
Test Coverage
#!/usr/bin/env ruby
#
# A Tic-tac-toe game client based on xo.

require 'xo'
include XO

AUTHOR = "Dwayne Crooks"
EMAIL  = "me@dwaynecrooks.com"

class Player

  def moves(grid, turn)
    raise NotImplementedError
  end

  private

    def all_open_moves(grid)
      moves = []

      grid.each_open do |r, c|
        moves << [r, c]
      end

      moves
    end

    def all_smart_moves(grid, turn)
      AI::Minimax.instance.moves(grid, turn)
    end
end

class Easy < Player

  def moves(grid, turn)
    all_open_moves(grid)
  end
end

class Medium < Player

  def moves(grid, turn)
    smart_moves = all_smart_moves(grid, turn)
    dumb_moves = all_open_moves(grid) - smart_moves

    dumb_moves.empty? ? smart_moves : (rand < 0.75 ? smart_moves : dumb_moves)
  end
end

class Hard < Player

  def moves(grid, turn)
    all_smart_moves(grid, turn)
  end
end

class Human

  def initialize(humans)
    @humans = humans
  end

  def get_move(grid, turn)
    puts grid
    puts

    puts "Enter your move in the format r, c."
    prompt_for_move(grid, turn)
  end

  def handle_next_turn(grid)
    puts grid
    puts
  end

  def handle_game_over(grid, event)
    case event[:type]
    when :winner
      puts "Congratulations! #{@humans == 2 ? event[:last_move][:turn] : 'You'} won."
    when :squashed
      puts "Sorry! Game squashed."
    end

    puts
  end

  def handle_invalid_move(event)
    puts "Sorry! You cannot make that move."

    case event[:type]
    when :occupied
      puts "That position is occupied."
    when :out_of_bounds
      puts "That position is not on the board."
    end

    puts
  end

  private

    def prompt_for_move(grid, turn)
      print "> "

      position = gets.chomp.split(',').map(&:strip).map(&:to_i)

      if position.length == 2
        puts
        position
      else
        prompt_for_move(grid, turn)
      end
    end
end

class Computer

  def initialize(humans, ai)
    @humans = humans
    @ai = ai
  end

  def get_move(grid, turn)
    puts "Waiting for the computer to play..." if @humans == 1

    r, c = @ai.moves(grid, turn).shuffle[0]

    if @humans == 1
      puts "The computer played at #{r}, #{c}."
      puts
    end

    [r, c]
  end

  def handle_next_turn(grid)
  end

  def handle_game_over(grid, event)
    if @humans == 0
      case event[:type]
      when :winner
        puts "#{event[:last_move][:turn]} wins!"
      when :squashed
        puts "Game squashed!"
      end
    elsif @humans == 1
      puts grid
      puts

      case event[:type]
      when :winner
        puts "Sorry! You lost."
      when :squashed
        puts "Sorry! Game squashed."
      end

      puts
    end
  end

  def handle_invalid_move(event)
  end
end

class Game

  def initialize(players)
    @players = players

    @engine = Engine.new
    @engine.add_observer(self)
  end

  def start(turn = false)
    turn ? engine.start(turn) : engine.continue_playing
  end

  def run
    run_one_turn until engine.current_state == GameOver
  end

  def update(event)
    player = players[engine.turn]

    case event[:name]
    when :next_turn
      player.handle_next_turn(engine.grid)
    when :game_over
      player.handle_game_over(engine.grid, event)
    when :invalid_move
      player.handle_invalid_move(event)
    end
  end

  private

    attr_reader :players, :engine

    def run_one_turn
      turn = engine.turn
      r, c = players[turn].get_move(engine.grid, turn)

      engine.play(r, c)
    end
end

def welcome
  heading = "Welcome to xo! A Tic-tac-toe game client created by #{AUTHOR}."

  puts heading
  puts "=" * heading.length
  puts

  puts "NOTE: You can exit the game at anytime by pressing CTRL-C."
  puts
end

def ask_for_difficulty_level
  puts "Please select your difficulty level:"
  puts "1. Easy"
  puts "2. Medium"
  puts "3. Hard"

  get_option({ '1' => Easy, '2' => Medium, '3' => Hard })
end

def ask_for_token
  puts "Do you want to play as x or o?"

  get_option({ 'x' => Grid::X, 'o' => Grid::O })
end

def ask_to_play_first
  puts "Do you want to play first (y or n)?"

  get_option({ 'y' => true, 'n' => false })
end

def ask_to_play_again
  puts "Do you want to play again (y or n)?"

  get_option({ 'y' => true, 'n' => false })
end

def get_option(options)
  print "> "

  selection = gets.chomp

  if options.key?(selection)
    puts
    options[selection]
  else
    get_option(options)
  end
end

def say_goodbye
  puts "Thank you for playing."
  puts "Bye!"
end

def terminate
  puts
  puts
  say_goodbye
  exit
end

def set_up_ctrl_c_handler
  trap("INT") {
    terminate
  }
end

def main
  set_up_ctrl_c_handler

  welcome

  level = ask_for_difficulty_level

  human_token = ask_for_token
  computer_token = Grid.other_token(human_token)

  play_first = ask_to_play_first

  players = {}
  players[human_token] = Human.new(1)
  players[computer_token] = Computer.new(1, level.new)

  game = Game.new(players)
  game.start(play_first ? human_token : computer_token)

  loop do
    game.run

    play_again = ask_to_play_again
    break unless play_again

    game.start
  end

  say_goodbye
end

main