[SUMMARY] MUD Client (#45)

Obviously, the first challenge in writing a MUD client is to read messages from
the server and write messages to the server. With many Telnet applications this
is a downright trivial challenge, because they generally respond only after you
send a command. MUDs are constantly in motion though, as other characters and
the MUD itself trigger events. This means that the MUD may send you a message
in the middle of you typing a command. Handling that is just a little bit
trickier.

I dealt with the simultaneous read and write issue using two tools: Threads and
character-by-character reading from the terminal. Writing server messages to
the player is the easy part, so I'll explain how I did that first.

My solution launches a Thread that just continually reads from the server,
pushing each line read into a thread-safe queue. Those lines can later be
retrieved by the main event loop as needed.

Reading from the player is more complicated. First, you can't just call a
blocking method like gets(), because that would halt the writing Thread as well.
(Ruby Threads are in-process.) Instead, I put the terminal in "raw" mode, so I
could read a character at a time from it in a loop. Sadly, this made my
implementation viable for Unix only.

After each character is read from the keyboard, I need to check if we have
pending messages from the server that need to be written to the terminal. When
there are messages waiting though, we can't just print them directly. Consider
that the user might be in the middle of entering a command:

  > look Gr_

If we just add a server message there, we get a mess:

  > look GrGrog taunts you a second time!_

We need to separate the server message and then repeat the prompt and partial
input:

  > look Gr
  Grog taunts you a second time!
  
  > look Gr_

That's a little easier for the user to keep up with, but it requires us to keep
an input buffer, so we can constantly reprint partial inputs.

The other challenge with this week's quiz was how to allow the user to script
the program. I used the easiest thing I could think of here. I decided to
filter all input and output into two Proc objects, then make those Procs
globally available, and eval() a Ruby configuration file to allow customization.
While I was at it, I placed at much of the client internals in globally
accessible variables as possible, to further enhance scriptability.

That's the plan, let's see how it comes out in actual Ruby code:

  #!/usr/local/bin/ruby -w
  
  require "thread"
  require "socket"
  require "io/wait"
  
  # utility method
  def show_prompt
    puts "\r\n"
    print "#{$prompt} #{$output_buffer}"
    $stdout.flush
  end
  
  # ...

In the beginning, I'm just pulling in some standard libraries and declaring a
helper method.

The "thread" library gives me the thread-safe Queue class; "socket" brings in
the networking classes; and "io/wait" adds a ready?() method to STDIN I can use
to see if characters have been entered by the user.

The show_prompt() method does exactly as the name implies. It shows the prompt
and any characters the user has entered. Note that I have to output "\r\n" at
the end of each line, since the terminal will be in "raw" mode.

  # ...
  
  # prepare global (scriptable) data
  $input_buffer = Queue.new
  $output_buffer = String.new
  
  $end_session = false
  $prompt = ">"
  $reader = lambda { |line| $input_buffer << line.strip }
  $writer = lambda do |buffer|
    $server.puts "#{buffer}\r\n"
    buffer.replace("")
  end
  
  # open a connection
  begin
    host = ARGV.shift || "localhost"
    port = (ARGV.shift || 61676).to_i
    $server = TCPSocket.new(host, port)
  rescue
    puts "Unable to open a connection."
    exit
  end
  
  # eval() the config file to support scripting
  config = File.join(ENV["HOME"], ".mud_client_rc")
  if File.exists? config
    eval(File.read(config))
  else
    File.open(config, "w") { |file| file.puts(<<'END_CONFIG') }
  # Place any code you would would like to execute inside the Ruby MUD client
  # at start-up, in this file. This file is expected to be valid Ruby syntax.
  
  # Set $prompt to whatever you like as long as it supports to_s().
  
  # You can set $end_session = true to exit the program at any time.
  
  # $reader and $writer hold lambdas that are passes the line read from the
  # server and the line read from the user, respectively.

···

#
  # The default $reader is:
  # lambda { |line| $input_buffer << line.strip }
  #
  # The default $writer is:
  # lambda do |buffer|
  # $server.puts "#{buffer}\r\n"
  # buffer.replace("")
  # end
  
  END_CONFIG
  end
  
  # ...

This section is the scripting support. First you can see me declaring a bunch
of global variables. The second half of the above code looks for the file
"~/.mud_client_rc". If it exists, it's eval()ed to allow the modification of
the global variables. If it doesn't exist, the file is created with some
helpful tips in comment form.

  # ...
  
  # launch a Thread to read from the server
  Thread.new($server) do |socket|
    while line = socket.gets
      $reader[line]
    end
    
    puts "Connection closed."
    exit
  end
  
  # ...

This is the worker Thread described early in my plan.

It was pointed out on Ruby Talk, that the above line-by-line read will not work
with many MUDs. Many of them print a question, followed by some space (but no
newline characters) and expect you to answer the question on the same line.
Supporting that would require a switch from gets() to recvfrom() and then a
little code to detect the end of a message, be it a newline or a prompt.

  # ...
  
  # switch terminal to "raw" mode
  $terminal_state = `stty -g`
  system "stty raw -echo"

  show_prompt

  # main event loop
  until $end_session
    if $stdin.ready? # read from user
      character = $stdin.getc
      case character
      when ?\C-c
        break
      when ?\r, ?\n
        $writer[$output_buffer]

        show_prompt
      else
        $output_buffer << character

        print character.chr
        $stdout.flush
      end
    end

    break if $end_session

    unless $input_buffer.empty? # read from server
      puts "\r\n"
      puts "#{$input_buffer.shift}\r\n" until $input_buffer.empty?

      show_prompt
    end
  end
  
  # ...

The above is the main event loop described in my plan. It places the terminal
in "raw" mode, so calls to getc() won't require a return to be pressed. Then it
alternately watches for input from the keyboard and input from the server,
dealing with each as it comes in.

  # ...
  
  # clean up after ourselves
  puts "\r\n"
  $server.close
  END { system "stty #{$terminal_state}" }

Finally, we clean up our mess before shutting down, closing the connection and
returning the terminal to normal.

Next week's Ruby Quiz comes from inside the halls of NASA, thanks to our own
Bill Kleb...