From: Ruby Quiz Date: 2005-09-08T21:47:56+09:00 Subject: [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: 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...