MUD Client (#45)

Sy has been searching for a Ruby scriptable MUD client via Ruby Talk and so far, there hasn't been many helpful answers posted. Let's generate some.

This week's Ruby Quiz is to create a basic MUD client that must be scriptable in the Ruby programming language. That's pretty vague, so let me see if I can answer the questions I'm sure at least some of you still have.

What is a MUD?

MUD stands for Multi-User Dungeon/Dimension, depending on who you ask. They are old text-based game servers with many role playing game elements to them. Here's a fictional example of MUD playing:

> look
Sacred Grove

You are standing in the legendary resting place of The Dagger in The Stone.
Many have tried to free the mystical blade before, but none were worthy.

You can see the Castle of Evil to the west.

What's here:
The Dagger in The Stone

>look dagger
The all-powerful blade begs to be stolen!

>get dagger
You take the dagger. (Well, that was easy, wasn't it?)

>equip dagger
You are now the most dangerous warrior in the kingdom!

>west
The Gates of Castle of Evil

A very big, very black, very evil castle.

You can enter the castle to the north, or return to the Sacred Grove in
the east.

What's here:
Grog, Castle Guardian

>north
Grog move's in front of the gate and laughs mercilessly at you.

>kill grog
You slice Grog with the mighty dagger for 5 points of damage.
Grog chews off your left ear for 15 points of damage.

You swing at Grog and miss.
Grog breaks the little finger on your right hand for 10 points of damage.

...

If you would like to find some MUDs to play on, try a listing service like:

The MUD Connector

That siteh also has a MUD FAQ that probably answers a lot more questions than this short introduction:

The MUD FAQ

What is a MUD client?

While there are some advanced MUD protocols, the truth is that most of them talk to any Telnet client just fine. We will focus on that for this quiz, to keep things simple. Our goal is to create a Ruby scriptable Telnet client, more or less.

What would we want to script?

Different people would have different requests I'm sure, but I'll give a few examples. One idea is that you may want your client to recognize certain commands commands and expand them into many MUD actions:

> prep for battle

> equip Vorpal Sword
You ready your weapon of choice.

> equip Diamond Armor
You protect yourself and still manage to look good.

> wear Ring of Invisibility
Where did you go?

...

Another interesting possibility is to have functionality where you can execute code when certain output is seen from the server. Here's an example:

> kill grog
You slash Grog with the dagger for 2 points of damage.
Grog disarms you!

>get dagger
You take up the dagger.

You punch Grog in the mouth for 2 points of damage.
Grog sings. You take 25 points of damage to the ear drums.

>equip dagger
You're now armed and dangerous.

You slash Grog with the dagger for 5 points of damage.
Grog slugs you for 12 points of damage.

...

Here the idea is that the client noticed you were disarmed and automatically retrieved and equipped your weapon. This saved you from having to quickly type these commands in the middle of combat.

There are many other possibilities for scripting, but that gives us a starting point.


Quiz Summary

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:

prompt> look Gr_

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

prompt> look GrGrog taunts you a second time!_

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

prompt> look Gr
Grog taunts you a second time!

prompt> 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:

ruby
#!/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.

ruby
# ...

# 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.

ruby
# ...

# 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.

ruby
# ...

# 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.

ruby
# ...

# 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...