[SUMMARY] MUD Client (#45)

R

Ruby Quiz

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:

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

Ask a Question

Want to reply to this thread or ask your own question?

You'll need to choose a username for the site, which only take a couple of moments. After that, you can post your question and our members will help you out.

Ask a Question

Members online

No members online now.

Forum statistics

Threads
473,954
Messages
2,570,116
Members
46,704
Latest member
BernadineF

Latest Threads

Top