Share

Creating a Cinch plugin part 2: a word game bot for IRC

This is the second part of a tutorial for creating a cinch IRC bot game. If you haven't read the first part then you can read it here.

This part will go through the basics of creating a cinch plugin, and will give you a basic implementation of the word game.

Creating the folder structure

We'll use the same structure that's commonly used in gems. This makes it easier to package the cinch plugin as a gem at a later stage, if you want to do so. We want to have a top-level folder for our plugin, containing the structure lib/cinch/plugins.

$ mkdir -p cinch-wordgame/lib/cinch/plugins
$ cd cinch-wordgame/lib/cinch/plugins

Then, create a file called word_game.rb - this will contain our plugin. Add the following:

# lib/cinch/plugins/word_game.rb

require 'cinch'

module Cinch::Plugins
  class WordGame
    include Cinch::Plugin

    match(/word start/)
    def execute(m)
      m.reply "Starting a new word game"
    end
  end
end

Now, let's create a test file that we can use to run the bot. In the top directory ("cinch-wordgame") create a file called test.rb, which looks like this:

require 'cinch'
require_relative "lib/cinch/plugins/word_game"

bot = Cinch::Bot.new do
  configure do |c|
    c.server = "irc.freenode.net"
    c.channels = ["#wordgametest"]
    c.nick = "wordgame"

    c.plugins.plugins = [
      Cinch::Plugins::WordGame
    ]
  end
end

bot.start

If you run this file then your bot will join the IRC channel #wordgame on freenode. Join the channel and run !word start to see the bot reply with:

Starting a new word game

Well, it's not particularly exciting yet, but at least you now have an easy way of testing your changes. You can kill the bot's ruby process with Ctrl-C.

Multiple matches

We need a way of starting the game and guessing a word. That means that there are at least two different message matchers that we need to add for the same plugin. We already have !word start, but we also want something like !guess [word] to actually make a guess. Fortunately, that's easy to do:

  class WordGame
    include Cinch::Plugin

    match(/word start/, method: :start)
    def start(m)
      m.reply "Starting a new word game"
    end

    match(/guess (\S+)/, method: :guess)
    def guess(m, guessed_word)
      m.reply "Your guess was #{guessed_word}"
    end
  end

You can add multiple regular expression matches, and specify which method is used for each match. Our guess match records the word that the user gives, and passes it through to the guess method (regular expression group matches are passed as arguments).

This is a bit better, but it's nowhere near a game yet. To get to the point of it being a playable game, we need to meet the following criteria:

  1. The bot needs a list of words

  2. When the game starts, the bot should choose a random word

  3. When a guess is made, the bot should check if a game has been started

  4. If it has, the bot should say whether the guessed word comes before or after the bot's word (and also whether it is a real word)

  5. If the guess is correct then the bot should announce the winner and end the game

Loading some words

For this, we need a dictionary. If you're using Ubuntu, you already have one - you'll find it in "/etc/dictionaries-common/words". If you're not, download one here. It contains approximately 10,000 words, and it will do nicely for our purposes.

It makes sense to keep this concept encapsulated, so let's create a Dictionary class. We don't need it to be accessible outside of the WordGame class, so we can nest it for the time being:

class WordGame
  #...

  class Dictionary
    # Create a new dictionary, with words loaded from a file
    def self.from_file(filename)
      words = []
      File.foreach(filename) do |word|
        if word[0] == word[0].downcase && !word.include?("'")
          words << word.strip
        end
      end
      self.new(words)
    end

    def initialize(words)
      @words = words
    end

    def random_word
      @words.sample
    end

    def word_valid?(word)
      @words.include? word
    end
  end
end

The dictionary class is initialized with an array of words. To build it from a dictionary file, I've added a class method Dictionary.from_file(filename). This assumes that each line of the file is a new word. I noticed that the Ubuntu dictionary includes proper nouns and words with apostrophes, so we only add them to our array of words if they are lowercase and don't include an apostrophe.

The dictionary object gives us two methods, random_word, which returns a random word from the list, and word_valid?, which allows us to pass in a word to check whether it exists in our dictionary.

Now, we need to create a loaded dictionary class. We can put this in the plugin class' initialize method:

class WordGame
  include Cinch::Plugin

  def initialize(*)
    super
    @dict = Dictionary.from_file "/etc/dictionaries-common/words"
  end

  #...
end

Change the path to the location of your dictionary file. Eventually we'll make it a configuration option for the plugin, but it's fine for it to be hard-coded for the time being. Note that it's very important to call super, otherwise cinch won't be able to initialize the plugin properly.

Starting a game

When the game starts, we want to choose a random word, and save this word so that we can compare it against guesses. We also want an easy way of comparing a word against another. To do this, we can create a very simple wrapper class for a word (again, we'll nest it within the plugin class):

class WordGame
  #...
 
  class Word < Struct.new(:word)
    def before_or_after?(other_word)
      word < other_word ? "before" : "after"
    end

    def ==(other_word)
      word == other_word
    end
  end
end

Let's go back to our start method and create a Word to mark the start of a game:

class WordGame
  #...
  
  match(/word start/, method: :start)
  def start(m)
    m.reply "Starting a new word game"
    @word = Word.new @dict.random_word
  end
  
  #...
end

Making a guess

Now we can respond to guesses from the user. In our guess method:

class WordGame
  #...

  match(/guess (\S+)/, method: :guess)
  def guess(m, guessed_word)
    if @word
      if @dict.word_valid? guessed_word
        if @word == guessed_word
          m.reply "#{m.user}: congratulations, that's the word! You win!"
          @word = nil
        else
          m.reply "My word comes #{@word.before_or_after?(guessed_word)} #{guessed_word}."
        end
      else
        m.reply "#{m.user}: sorry, #{guessed_word} isn't a word. At least, as far as I know"
      end
    else
      m.reply "You haven't started a game yet. Use `!word start` to do that."
    end
  end

  #...
end

It looks like a lot is going on, but it's actually fairly simple - it's just that there are quite a few paths that a guess could take.

  1. Check to see whether a game has been started. If not, we let the user know how to start one.

  2. Check whether the guessed word is in our dictionary, and tell the user if it isn't.

  3. Finally, compare it to the random word we chose. If it's the same, then tell the user that they've one, and clear out the word. Otherwise, tell them whether the word comes before or after in the dictionary.

It's worth splitting this out into other methods, as the nested if statements scream "refactor me". However, you now have a working game - go and try it out! Here's the full code so far, just in case something went wrong in the following of this tutorial:

require 'cinch'

module Cinch::Plugins
  class WordGame
    include Cinch::Plugin

    def initialize(*)
      super
      @dict = Dictionary.from_file "/etc/dictionaries-common/words"
    end

    match(/word start/, method: :start)
    def start(m)
      m.reply "Starting a new word game"
      @word = Word.new @dict.random_word
    end

    match(/word peek/, method: :peek)
    def peek(m)
      m.reply "The word is #{@word.word}"
    end

    match(/guess (\S+)/, method: :guess)
    def guess(m, guessed_word)
      if @word
        if @dict.word_valid? guessed_word
          if @word == guessed_word
            m.reply "#{m.user}: congratulations, that's the word! You win!"
            @word = nil
          else
            m.reply "My word comes #{@word.before_or_after?(guessed_word)} #{guessed_word}."
          end
        else
          m.reply "#{m.user}: sorry, #{guessed_word} isn't a word. At least, as far as I know"
        end
      else
        m.reply "You haven't started a game yet. Use `!word start` to do that."
      end
    end

    class Dictionary
      def initialize(words)
        @words = words
      end

      def self.from_file(filename)
        words = []
        File.foreach(filename) do |word|
          if word[0] == word[0].downcase && !word.include?("'")
            words << word.strip
          end
        end
        self.new(words)
      end

      def initialize(words)
        @words = words
      end

      def random_word
        @words.sample
      end

      def word_valid?(word)
        @words.include? word
      end
    end

    class Word < Struct.new(:word)
      def before_or_after?(other_word)
        word < other_word ? "before" : "after"
      end

      def ==(other_word)
        word == other_word
      end
    end
  end
end

Cheating

When you're playing this for the first time, you probably want to check that it's working correctly. Therefore, you might like to add a peek command temporarily, which prints out the word:

class WordGame
  #...

  match(/word peek/, method: :peek)
  def peek(m)
    m.reply "The word is #{@word.word}"
  end

  #...
end

When you're happy that it's working, replace it with this cheat command, which ends the game if someone uses it (plus piles on the guilt):

class WordGame
  #...

  match(/word cheat/, method: :cheat)
  def cheat(m)
    m.reply "#{m.user}: really? You're giving up? Fine, the word is #{@word.word}"
    @word = nil
  end
  
  #...
end

Go and play

Congratulations, you now have a working word game! In the next and final part of the tutorial, I'll go through how to add configuration options and a help command.

← Previous post: What you need to know about bash functions Next post: Strange behaviour of Enumerable#each_with_object and array concatenation →
comments powered by Disqus