1 """Core objects for the mudpy engine."""
3 # Copyright (c) 2005 mudpy, Jeremy Stanley <fungi@yuggoth.org>, all rights reserved.
4 # Licensed per terms in the LICENSE file distributed with this software.
6 # import some things we need
7 from ConfigParser import RawConfigParser
8 from md5 import new as new_md5
9 from os import R_OK, access, chmod, makedirs, stat
10 from os.path import abspath, dirname, exists, isabs, join as path_join
11 from random import choice, randrange
12 from socket import AF_INET, SO_REUSEADDR, SOCK_STREAM, SOL_SOCKET, socket
13 from stat import S_IMODE, ST_MODE
14 from syslog import LOG_PID, LOG_INFO, LOG_DAEMON, closelog, openlog, syslog
15 from telnetlib import DO, DONT, ECHO, EOR, GA, IAC, LINEMODE, SB, SE, SGA, WILL, WONT
16 from time import asctime, sleep
19 """An element of the universe."""
20 def __init__(self, key, universe, origin=""):
21 """Default values for the in-memory element variables."""
23 if self.key.find(":") > 0:
24 self.category, self.subkey = self.key.split(":", 1)
26 self.category = "other"
27 self.subkey = self.key
28 if not self.category in universe.categories: self.category = "other"
29 universe.categories[self.category][self.subkey] = self
31 if not self.origin: self.origin = universe.default_origins[self.category]
32 if not isabs(self.origin):
33 self.origin = abspath(self.origin)
34 universe.contents[self.key] = self
35 if not self.origin in universe.files:
36 DataFile(self.origin, universe)
37 if not universe.files[self.origin].data.has_section(self.key):
38 universe.files[self.origin].data.add_section(self.key)
40 """Remove an element from the universe and destroy it."""
41 log("Destroying: " + self.key + ".")
42 universe.files[self.origin].data.remove_section(self.key)
43 del universe.categories[self.category][self.subkey]
44 del universe.contents[self.key]
46 def delete(self, facet):
47 """Delete a facet from the element."""
48 if universe.files[self.origin].data.has_option(self.key, facet):
49 universe.files[self.origin].data.remove_option(self.key, facet)
51 """Return a list of facets for this element."""
52 return universe.files[self.origin].data.options(self.key)
53 def get(self, facet, default=None):
54 """Retrieve values."""
55 if default is None: default = ""
56 if universe.files[self.origin].data.has_option(self.key, facet):
57 return universe.files[self.origin].data.get(self.key, facet)
59 def getboolean(self, facet, default=None):
60 """Retrieve values as boolean type."""
61 if default is None: default=False
62 if universe.files[self.origin].data.has_option(self.key, facet):
63 return universe.files[self.origin].data.getboolean(self.key, facet)
65 def getint(self, facet, default=None):
66 """Return values as int/long type."""
67 if default is None: default = 0
68 if universe.files[self.origin].data.has_option(self.key, facet):
69 return universe.files[self.origin].data.getint(self.key, facet)
71 def getfloat(self, facet, default=None):
72 """Return values as float type."""
73 if default is None: default = 0.0
74 if universe.files[self.origin].data.has_option(self.key, facet):
75 return universe.files[self.origin].data.getfloat(self.key, facet)
77 def getlist(self, facet, default=None):
78 """Return values as list type."""
79 if default is None: default = []
80 value = self.get(facet)
81 if value: return makelist(value)
83 def getdict(self, facet, default=None):
84 """Return values as dict type."""
85 if default is None: default = {}
86 value = self.get(facet)
87 if value: return makedict(value)
89 def set(self, facet, value):
91 if type(value) is long: value = str(value)
92 elif not type(value) is str: value = repr(value)
93 universe.files[self.origin].data.set(self.key, facet, value)
96 """A file containing universe elements."""
97 def __init__(self, filename, universe):
98 self.data = RawConfigParser()
99 if access(filename, R_OK): self.data.read(filename)
100 self.filename = filename
101 universe.files[filename] = self
102 if self.data.has_option("control", "include_files"):
103 includes = makelist(self.data.get("control", "include_files"))
105 if self.data.has_option("control", "default_files"):
106 origins = makedict(self.data.get("control", "default_files"))
107 for key in origins.keys():
108 if not key in includes: includes.append(key)
109 universe.default_origins[key] = origins[key]
110 if not key in universe.categories:
111 universe.categories[key] = {}
112 if self.data.has_option("control", "private_files"):
113 for item in makelist(self.data.get("control", "private_files")):
114 if not item in includes: includes.append(item)
115 if not item in universe.private_files:
117 item = path_join(dirname(filename), item)
118 universe.private_files.append(item)
119 for section in self.data.sections():
120 if section != "control":
121 Element(section, universe, filename)
122 for include_file in includes:
123 if not isabs(include_file):
124 include_file = path_join(dirname(filename), include_file)
125 DataFile(include_file, universe)
127 if ( self.data.sections() or exists(self.filename) ) and not ( self.data.has_option("control", "read_only") and self.data.getboolean("control", "read_only") ):
128 if not exists(dirname(self.filename)): makedirs(dirname(self.filename))
129 file_descriptor = file(self.filename, "w")
130 if self.filename in universe.private_files and oct(S_IMODE(stat(self.filename)[ST_MODE])) != 0600:
131 chmod(self.filename, 0600)
132 self.data.write(file_descriptor)
133 file_descriptor.flush()
134 file_descriptor.close()
138 def __init__(self, filename=""):
139 """Initialize the universe."""
142 self.default_origins = {}
144 self.private_files = []
146 self.terminate_world = False
147 self.reload_modules = False
149 possible_filenames = [
155 "/usr/local/mudpy/mudpy.conf",
156 "/usr/local/mudpy/etc/mudpy.conf",
157 "/etc/mudpy/mudpy.conf",
160 for filename in possible_filenames:
161 if access(filename, R_OK): break
162 if not isabs(filename):
163 filename = abspath(filename)
164 DataFile(filename, self)
166 """Save the universe to persistent storage."""
167 for key in self.files: self.files[key].save()
169 def initialize_server_socket(self):
170 """Create and open the listening socket."""
172 # create a new ipv4 stream-type socket object
173 self.listening_socket = socket(AF_INET, SOCK_STREAM)
175 # set the socket options to allow existing open ones to be
176 # reused (fixes a bug where the server can't bind for a minute
177 # when restarting on linux systems)
178 self.listening_socket.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
180 # bind the socket to to our desired server ipa and port
181 self.listening_socket.bind((self.categories["internal"]["network"].get("host"), self.categories["internal"]["network"].getint("port")))
183 # disable blocking so we can proceed whether or not we can
185 self.listening_socket.setblocking(0)
187 # start listening on the socket
188 self.listening_socket.listen(1)
190 # note that we're now ready for user connections
191 log("Waiting for connection(s)...")
194 """This is a connected user."""
197 """Default values for the in-memory user variables."""
199 self.last_address = ""
200 self.connection = None
201 self.authenticated = False
202 self.password_tries = 0
203 self.state = "initial"
204 self.menu_seen = False
206 self.input_queue = []
207 self.output_queue = []
208 self.partial_input = ""
210 self.terminator = IAC+GA
211 self.negotiation_pause = 0
216 """Log, close the connection and remove."""
217 if self.account: name = self.account.get("name")
219 if name: message = "User " + name
220 else: message = "An unnamed user"
221 message += " logged out."
223 self.connection.close()
227 """Save, load a new user and relocate the connection."""
229 # get out of the list
232 # create a new user object
235 # set everything else equivalent
252 exec("new_user." + attribute + " = self." + attribute)
255 universe.userlist.append(new_user)
257 # get rid of the old user object
260 def replace_old_connections(self):
261 """Disconnect active users with the same name."""
263 # the default return value
266 # iterate over each user in the list
267 for old_user in universe.userlist:
269 # the name is the same but it's not us
270 if old_user.account.get("name") == self.account.get("name") and old_user is not self:
273 log("User " + self.account.get("name") + " reconnected--closing old connection to " + old_user.address + ".")
274 old_user.send("$(eol)$(red)New connection from " + self.address + ". Terminating old connection...$(nrm)$(eol)")
275 self.send("$(eol)$(red)Taking over old connection from " + old_user.address + ".$(nrm)")
277 # close the old connection
278 old_user.connection.close()
280 # replace the old connection with this one
281 old_user.connection = self.connection
282 old_user.last_address = old_user.address
283 old_user.address = self.address
284 old_user.echoing = self.echoing
286 # take this one out of the list and delete
292 # true if an old connection was replaced, false if not
295 def authenticate(self):
296 """Flag the user as authenticated and disconnect duplicates."""
297 if not self.state is "authenticated":
298 log("User " + self.account.get("name") + " logged in.")
299 self.authenticated = True
302 """Send the user their current menu."""
303 if not self.menu_seen:
304 self.menu_choices = get_menu_choices(self)
305 self.send(get_menu(self.state, self.error, self.echoing, self.terminator, self.menu_choices), "")
306 self.menu_seen = True
308 self.adjust_echoing()
310 def adjust_echoing(self):
311 """Adjust echoing to match state menu requirements."""
312 if self.echoing and not menu_echo_on(self.state): self.echoing = False
313 elif not self.echoing and menu_echo_on(self.state): self.echoing = True
316 """Remove a user from the list of connected users."""
317 universe.userlist.remove(self)
319 def send(self, output, eol="$(eol)", raw=False):
320 """Send arbitrary text to a connected user."""
322 # unless raw mode is on, clean it up all nice and pretty
325 # we'll take out GA or EOR and add them back on the end
326 if output.endswith(IAC+GA) or output.endswith(IAC+EOR):
329 else: terminate = False
331 # start with a newline, append the message, then end
332 # with the optional eol string passed to this function
333 # and the ansi escape to return to normal text
334 output = "\r\n" + output + eol + chr(27) + "[0m"
336 # find and replace macros in the output
337 output = replace_macros(self, output)
339 # wrap the text at 80 characters
340 output = wrap_ansi_text(output, 80)
342 # tack the terminator back on
343 if terminate: output += self.terminator
345 # drop the output into the user's output queue
346 self.output_queue.append(output)
349 """All the things to do to the user per increment."""
351 # if the world is terminating, disconnect
352 if universe.terminate_world:
353 self.state = "disconnecting"
354 self.menu_seen = False
356 # if output is paused, decrement the counter
357 if self.state == "initial":
358 if self.negotiation_pause: self.negotiation_pause -= 1
359 else: self.state = "entering_account_name"
361 # show the user a menu as needed
362 else: self.show_menu()
364 # disconnect users with the appropriate state
365 if self.state == "disconnecting": self.quit()
367 # the user is unique and not flagged to disconnect
370 # try to send the last item in the queue and remove it
371 if self.output_queue:
373 self.connection.send(self.output_queue[0])
374 del self.output_queue[0]
376 # but if we can't, that's okay too
380 # check for input and add it to the queue
383 # there is input waiting in the queue
384 if self.input_queue: handle_user_input(self)
386 def enqueue_input(self):
387 """Process and enqueue any new input."""
389 # check for some input
391 input_data = self.connection.recv(1024)
398 # tack this on to any previous partial
399 self.partial_input += input_data
401 # reply to and remove any IAC negotiation codes
402 self.negotiate_telnet_options()
404 # separate multiple input lines
405 new_input_lines = self.partial_input.split("\n")
407 # if input doesn't end in a newline, replace the
408 # held partial input with the last line of it
409 if not self.partial_input.endswith("\n"):
410 self.partial_input = new_input_lines.pop()
412 # otherwise, chop off the extra null input and reset
413 # the held partial input
415 new_input_lines.pop()
416 self.partial_input = ""
418 # iterate over the remaining lines
419 for line in new_input_lines:
421 # remove a trailing carriage return
422 if line.endswith("\r"): line = line.rstrip("\r")
424 # log non-printable characters remaining
425 removed = filter(lambda x: (x < " " or x > "~"), line)
427 logline = "Non-printable characters from "
428 if self.account and self.account.get("name"): logline += self.account.get("name") + ": "
429 else: logline += "unknown user: "
430 logline += repr(removed)
433 # filter out non-printables
434 line = filter(lambda x: " " <= x <= "~", line)
436 # strip off extra whitespace
439 # put on the end of the queue
440 self.input_queue.append(line)
442 def negotiate_telnet_options(self):
443 """Reply to/remove partial_input telnet negotiation options."""
445 # start at the begining of the input
448 # make a local copy to play with
449 text = self.partial_input
451 # as long as we haven't checked it all
452 while position < len(text):
454 # jump to the first IAC you find
455 position = text.find(IAC, position)
457 # if there wasn't an IAC in the input, skip to the end
458 if position < 0: position = len(text)
460 # replace a double (literal) IAC if there's an LF later
461 elif len(text) > position+1 and text[position+1] == IAC:
462 if text.find("\n", position) > 0: text = text.replace(IAC+IAC, IAC)
466 # this must be an option negotiation
467 elif len(text) > position+2 and text[position+1] in (DO, DONT, WILL, WONT):
469 negotiation = text[position+1:position+3]
471 # if we turned echo off, ignore the confirmation
472 if not self.echoing and negotiation == DO+ECHO: pass
475 elif negotiation == WILL+LINEMODE: self.send(IAC+DO+LINEMODE, raw=True)
477 # if the client likes EOR instead of GA, make a note of it
478 elif negotiation == DO+EOR: self.terminator = IAC+EOR
479 elif negotiation == DONT+EOR and self.terminator == IAC+EOR:
480 self.terminator = IAC+GA
482 # if the client doesn't want GA, oblige
483 elif negotiation == DO+SGA and self.terminator == IAC+GA:
485 self.send(IAC+WILL+SGA, raw=True)
487 # we don't want to allow anything else
488 elif text[position+1] == DO: self.send(IAC+WONT+text[position+2], raw=True)
489 elif text[position+1] == WILL: self.send(IAC+DONT+text[position+2], raw=True)
491 # strip the negotiation from the input
492 text = text.replace(text[position:position+3], "")
494 # get rid of IAC SB .* IAC SE
495 elif len(text) > position+4 and text[position:position+2] == IAC+SB:
496 end_subnegotiation = text.find(IAC+SE, position)
497 if end_subnegotiation > 0: text = text[:position] + text[end_subnegotiation+2:]
500 # otherwise, strip out a two-byte IAC command
501 elif len(text) > position+2: text = text.replace(text[position:position+2], "")
503 # and this means we got the begining of an IAC
506 # replace the input with our cleaned-up text
507 self.partial_input = text
509 def can_run(self, command):
510 """Check if the user can run this command object."""
512 # has to be in the commands category
513 if command not in universe.categories["command"].values(): result = False
515 # administrators can run any command
516 elif self.account.getboolean("administrator"): result = True
518 # everyone can run non-administrative commands
519 elif not command.getboolean("administrative"): result = True
521 # otherwise the command cannot be run by this user
524 # pass back the result
527 def new_avatar(self):
528 """Instantiate a new, unconfigured avatar for this user."""
530 while "avatar:" + self.account.get("name") + ":" + str(counter) in universe.categories["actor"].keys(): counter += 1
531 self.avatar = Element("actor:avatar:" + self.account.get("name") + ":" + str(counter), universe)
532 avatars = self.account.getlist("avatars")
533 avatars.append(self.avatar.key)
534 self.account.set("avatars", avatars)
536 def delete_avatar(self, avatar):
537 """Remove an avatar from the world and from the user's list."""
538 if self.avatar is universe.contents[avatar]: self.avatar = None
539 universe.contents[avatar].destroy()
540 avatars = self.account.getlist("avatars")
541 avatars.remove(avatar)
542 self.account.set("avatars", avatars)
545 """Destroy the user and associated avatars."""
546 for avatar in self.account.getlist("avatars"): self.delete_avatar(avatar)
547 self.account.destroy()
549 def list_avatar_names(self):
550 """List names of assigned avatars."""
551 return [ universe.contents[avatar].get("name") for avatar in self.account.getlist("avatars") ]
554 """Turn string into list type."""
555 if value[0] + value[-1] == "[]": return eval(value)
556 else: return [ value ]
559 """Turn string into dict type."""
560 if value[0] + value[-1] == "{}": return eval(value)
561 elif value.find(":") > 0: return eval("{" + value + "}")
562 else: return { value: None }
564 def broadcast(message):
565 """Send a message to all connected users."""
566 for each_user in universe.userlist: each_user.send("$(eol)" + message)
571 # the time in posix log timestamp format
572 timestamp = asctime()[4:19]
574 # send the timestamp and message to standard output
575 print(timestamp + " " + message)
577 # send the message to the system log
578 openlog("mudpy", LOG_PID, LOG_INFO | LOG_DAEMON)
582 def wrap_ansi_text(text, width):
583 """Wrap text with arbitrary width while ignoring ANSI colors."""
585 # the current position in the entire text string, including all
586 # characters, printable or otherwise
587 absolute_position = 0
589 # the current text position relative to the begining of the line,
590 # ignoring color escape sequences
591 relative_position = 0
593 # whether the current character is part of a color escape sequence
596 # iterate over each character from the begining of the text
597 for each_character in text:
599 # the current character is the escape character
600 if each_character == chr(27):
603 # the current character is within an escape sequence
606 # the current character is m, which terminates the
607 # current escape sequence
608 if each_character == "m":
611 # the current character is a newline, so reset the relative
612 # position (start a new line)
613 elif each_character == "\n":
614 relative_position = 0
616 # the current character meets the requested maximum line width,
617 # so we need to backtrack and find a space at which to wrap
618 elif relative_position == width:
620 # distance of the current character examined from the
624 # count backwards until we find a space
625 while text[absolute_position - wrap_offset] != " ":
628 # insert an eol in place of the space
629 text = text[:absolute_position - wrap_offset] + "\r\n" + text[absolute_position - wrap_offset + 1:]
631 # increase the absolute position because an eol is two
632 # characters but the space it replaced was only one
633 absolute_position += 1
635 # now we're at the begining of a new line, plus the
636 # number of characters wrapped from the previous line
637 relative_position = wrap_offset
639 # as long as the character is not a carriage return and the
640 # other above conditions haven't been met, count it as a
641 # printable character
642 elif each_character != "\r":
643 relative_position += 1
645 # increase the absolute position for every character
646 absolute_position += 1
648 # return the newly-wrapped text
651 def weighted_choice(data):
652 """Takes a dict weighted by value and returns a random key."""
654 # this will hold our expanded list of keys from the data
657 # create thee expanded list of keys
658 for key in data.keys():
659 for count in range(data[key]):
662 # return one at random
663 return choice(expanded)
666 """Returns a random character name."""
668 # the vowels and consonants needed to create romaji syllables
669 vowels = [ "a", "i", "u", "e", "o" ]
670 consonants = ["'", "k", "z", "s", "sh", "z", "j", "t", "ch", "ts", "d", "n", "h", "f", "m", "y", "r", "w" ]
672 # this dict will hold our weighted list of syllables
675 # generate the list with an even weighting
676 for consonant in consonants:
678 syllables[consonant + vowel] = 1
680 # we'll build the name into this string
683 # create a name of random length from the syllables
684 for syllable in range(randrange(2, 6)):
685 name += weighted_choice(syllables)
687 # strip any leading quotemark, capitalize and return the name
688 return name.strip("'").capitalize()
690 def replace_macros(user, text, is_input=False):
691 """Replaces macros in text output."""
696 # third person pronouns
698 "female": { "obj": "her", "pos": "hers", "sub": "she" },
699 "male": { "obj": "him", "pos": "his", "sub": "he" },
700 "neuter": { "obj": "it", "pos": "its", "sub": "it" }
703 # a dict of replacement macros
706 "$(bld)": chr(27) + "[1m",
707 "$(nrm)": chr(27) + "[0m",
708 "$(blk)": chr(27) + "[30m",
709 "$(grn)": chr(27) + "[32m",
710 "$(red)": chr(27) + "[31m",
713 # add dynamic macros where possible
715 account_name = user.account.get("name")
717 macros["$(account)"] = account_name
719 avatar_gender = user.avatar.get("gender")
721 macros["$(tpop)"] = pronouns[avatar_gender]["obj"]
722 macros["$(tppp)"] = pronouns[avatar_gender]["pos"]
723 macros["$(tpsp)"] = pronouns[avatar_gender]["sub"]
725 # find and replace per the macros dict
726 macro_start = text.find("$(")
727 if macro_start == -1: break
728 macro_end = text.find(")", macro_start) + 1
729 macro = text[macro_start:macro_end]
730 if macro in macros.keys():
731 text = text.replace(macro, macros[macro])
733 # if we get here, log and replace it with null
735 text = text.replace(macro, "")
737 log("Unexpected replacement macro " + macro + " encountered.")
739 # replace the look-like-a-macro sequence
740 text = text.replace("$_(", "$(")
744 def escape_macros(text):
745 """Escapes replacement macros in text."""
746 return text.replace("$(", "$_(")
748 def check_time(frequency):
749 """Check for a factor of the current increment count."""
750 if type(frequency) is str:
751 frequency = universe.categories["internal"]["time"].getint(frequency)
752 if not "counters" in universe.categories["internal"]:
753 Element("internal:counters", universe)
754 return not universe.categories["internal"]["counters"].getint("elapsed") % frequency
757 """The things which should happen on each pulse, aside from reloads."""
759 # open the listening socket if it hasn't been already
760 if not hasattr(universe, "listening_socket"):
761 universe.initialize_server_socket()
763 # assign a user if a new connection is waiting
764 user = check_for_connection(universe.listening_socket)
765 if user: universe.userlist.append(user)
767 # iterate over the connected users
768 for user in universe.userlist: user.pulse()
770 # update the log every now and then
771 if check_time("frequency_log"):
772 log(str(len(universe.userlist)) + " connection(s)")
774 # periodically save everything
775 if check_time("frequency_save"):
778 # pause for a configurable amount of time (decimal seconds)
779 sleep(universe.categories["internal"]["time"].getfloat("increment"))
781 # increment the elapsed increment counter
782 universe.categories["internal"]["counters"].set("elapsed", universe.categories["internal"]["counters"].getint("elapsed") + 1)
785 """Reload data into new persistent objects."""
786 for user in universe.userlist[:]: user.reload()
788 def check_for_connection(listening_socket):
789 """Check for a waiting connection and return a new user object."""
791 # try to accept a new connection
793 connection, address = listening_socket.accept()
797 # note that we got one
798 log("Connection from " + address[0])
800 # disable blocking so we can proceed whether or not we can send/receive
801 connection.setblocking(0)
803 # create a new user object
806 # associate this connection with it
807 user.connection = connection
809 # set the user's ipa from the connection's ipa
810 user.address = address[0]
812 # let the client know we WILL EOR
813 user.send(IAC+WILL+EOR, raw=True)
814 user.negotiation_pause = 2
816 # return the new user object
819 def get_menu(state, error=None, echoing=True, terminator="", choices=None):
820 """Show the correct menu text to a user."""
822 # make sure we don't reuse a mutable sequence by default
823 if choices is None: choices = {}
825 # begin with a telnet echo command sequence if needed
826 message = get_echo_sequence(state, echoing)
828 # get the description or error text
829 message += get_menu_description(state, error)
831 # get menu choices for the current state
832 message += get_formatted_menu_choices(state, choices)
834 # try to get a prompt, if it was defined
835 message += get_menu_prompt(state)
837 # throw in the default choice, if it exists
838 message += get_formatted_default_menu_choice(state)
840 # display a message indicating if echo is off
841 message += get_echo_message(state)
843 # tack on EOR or GA to indicate the prompt will not be followed by CRLF
844 message += terminator
846 # return the assembly of various strings defined above
849 def menu_echo_on(state):
850 """True if echo is on, false if it is off."""
851 return universe.categories["menu"][state].getboolean("echo", True)
853 def get_echo_sequence(state, echoing):
854 """Build the appropriate IAC WILL/WONT ECHO sequence as needed."""
856 # if the user has echo on and the menu specifies it should be turned
857 # off, send: iac + will + echo + null
858 if echoing and not menu_echo_on(state): return IAC+WILL+ECHO
860 # if echo is not set to off in the menu and the user curently has echo
861 # off, send: iac + wont + echo + null
862 elif not echoing and menu_echo_on(state): return IAC+WONT+ECHO
864 # default is not to send an echo control sequence at all
867 def get_echo_message(state):
868 """Return a message indicating that echo is off."""
869 if menu_echo_on(state): return ""
870 else: return "(won't echo) "
872 def get_default_menu_choice(state):
873 """Return the default choice for a menu."""
874 return universe.categories["menu"][state].get("default")
876 def get_formatted_default_menu_choice(state):
877 """Default menu choice foratted for inclusion in a prompt string."""
878 default_choice = get_default_menu_choice(state)
879 if default_choice: return "[$(red)" + default_choice + "$(nrm)] "
882 def get_menu_description(state, error):
883 """Get the description or error text."""
885 # an error condition was raised by the handler
888 # try to get an error message matching the condition
890 description = universe.categories["menu"][state].get("error_" + error)
891 if not description: description = "That is not a valid choice..."
892 description = "$(red)" + description + "$(nrm)"
894 # there was no error condition
897 # try to get a menu description for the current state
898 description = universe.categories["menu"][state].get("description")
900 # return the description or error message
901 if description: description += "$(eol)$(eol)"
904 def get_menu_prompt(state):
905 """Try to get a prompt, if it was defined."""
906 prompt = universe.categories["menu"][state].get("prompt")
907 if prompt: prompt += " "
910 def get_menu_choices(user):
911 """Return a dict of choice:meaning."""
912 menu = universe.categories["menu"][user.state]
913 create_choices = menu.get("create")
914 if create_choices: choices = eval(create_choices)
919 for facet in menu.facets():
920 if facet.startswith("demand_") and not eval(universe.categories["menu"][user.state].get(facet)):
921 ignores.append(facet.split("_", 2)[1])
922 elif facet.startswith("create_"):
923 creates[facet] = facet.split("_", 2)[1]
924 elif facet.startswith("choice_"):
925 options[facet] = facet.split("_", 2)[1]
926 for facet in creates.keys():
927 if not creates[facet] in ignores:
928 choices[creates[facet]] = eval(menu.get(facet))
929 for facet in options.keys():
930 if not options[facet] in ignores:
931 choices[options[facet]] = menu.get(facet)
934 def get_formatted_menu_choices(state, choices):
935 """Returns a formatted string of menu choices."""
937 choice_keys = choices.keys()
939 for choice in choice_keys:
940 choice_output += " [$(red)" + choice + "$(nrm)] " + choices[choice] + "$(eol)"
941 if choice_output: choice_output += "$(eol)"
944 def get_menu_branches(state):
945 """Return a dict of choice:branch."""
947 for facet in universe.categories["menu"][state].facets():
948 if facet.startswith("branch_"):
949 branches[facet.split("_", 2)[1]] = universe.categories["menu"][state].get(facet)
952 def get_default_branch(state):
953 """Return the default branch."""
954 return universe.categories["menu"][state].get("branch")
956 def get_choice_branch(user, choice):
957 """Returns the new state matching the given choice."""
958 branches = get_menu_branches(user.state)
959 if choice in branches.keys(): return branches[choice]
960 elif choice in user.menu_choices.keys(): return get_default_branch(user.state)
963 def get_menu_actions(state):
964 """Return a dict of choice:branch."""
966 for facet in universe.categories["menu"][state].facets():
967 if facet.startswith("action_"):
968 actions[facet.split("_", 2)[1]] = universe.categories["menu"][state].get(facet)
971 def get_default_action(state):
972 """Return the default action."""
973 return universe.categories["menu"][state].get("action")
975 def get_choice_action(user, choice):
976 """Run any indicated script for the given choice."""
977 actions = get_menu_actions(user.state)
978 if choice in actions.keys(): return actions[choice]
979 elif choice in user.menu_choices.keys(): return get_default_action(user.state)
982 def handle_user_input(user):
983 """The main handler, branches to a state-specific handler."""
985 # check to make sure the state is expected, then call that handler
986 if "handler_" + user.state in globals():
987 exec("handler_" + user.state + "(user)")
989 generic_menu_handler(user)
991 # since we got input, flag that the menu/prompt needs to be redisplayed
992 user.menu_seen = False
994 # if the user's client echo is off, send a blank line for aesthetics
995 if not user.echoing: user.send("", "")
997 def generic_menu_handler(user):
998 """A generic menu choice handler."""
1000 # get a lower-case representation of the next line of input
1001 if user.input_queue:
1002 choice = user.input_queue.pop(0)
1003 if choice: choice = choice.lower()
1005 if not choice: choice = get_default_menu_choice(user.state)
1006 if choice in user.menu_choices:
1007 exec(get_choice_action(user, choice))
1008 new_state = get_choice_branch(user, choice)
1009 if new_state: user.state = new_state
1010 else: user.error = "default"
1012 def handler_entering_account_name(user):
1013 """Handle the login account name."""
1015 # get the next waiting line of input
1016 input_data = user.input_queue.pop(0)
1018 # did the user enter anything?
1021 # keep only the first word and convert to lower-case
1022 name = input_data.lower()
1024 # fail if there are non-alphanumeric characters
1025 if name != filter(lambda x: x>="0" and x<="9" or x>="a" and x<="z", name):
1026 user.error = "bad_name"
1028 # if that account exists, time to request a password
1029 elif name in universe.categories["account"]:
1030 user.account = universe.categories["account"][name]
1031 user.state = "checking_password"
1033 # otherwise, this could be a brand new user
1035 user.account = Element("account:" + name, universe)
1036 user.account.set("name", name)
1037 log("New user: " + name)
1038 user.state = "checking_new_account_name"
1040 # if the user entered nothing for a name, then buhbye
1042 user.state = "disconnecting"
1044 def handler_checking_password(user):
1045 """Handle the login account password."""
1047 # get the next waiting line of input
1048 input_data = user.input_queue.pop(0)
1050 # does the hashed input equal the stored hash?
1051 if new_md5(user.account.get("name") + input_data).hexdigest() == user.account.get("passhash"):
1053 # if so, set the username and load from cold storage
1054 if not user.replace_old_connections():
1056 user.state = "main_utility"
1058 # if at first your hashes don't match, try, try again
1059 elif user.password_tries < universe.categories["internal"]["limits"].getint("password_tries") - 1:
1060 user.password_tries += 1
1061 user.error = "incorrect"
1063 # we've exceeded the maximum number of password failures, so disconnect
1065 user.send("$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)")
1066 user.state = "disconnecting"
1068 def handler_entering_new_password(user):
1069 """Handle a new password entry."""
1071 # get the next waiting line of input
1072 input_data = user.input_queue.pop(0)
1074 # make sure the password is strong--at least one upper, one lower and
1075 # one digit, seven or more characters in length
1076 if len(input_data) > 6 and len(filter(lambda x: x>="0" and x<="9", input_data)) and len(filter(lambda x: x>="A" and x<="Z", input_data)) and len(filter(lambda x: x>="a" and x<="z", input_data)):
1078 # hash and store it, then move on to verification
1079 user.account.set("passhash", new_md5(user.account.get("name") + input_data).hexdigest())
1080 user.state = "verifying_new_password"
1082 # the password was weak, try again if you haven't tried too many times
1083 elif user.password_tries < universe.categories["internal"]["limits"].getint("password_tries") - 1:
1084 user.password_tries += 1
1087 # too many tries, so adios
1089 user.send("$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)")
1090 user.account.destroy()
1091 user.state = "disconnecting"
1093 def handler_verifying_new_password(user):
1094 """Handle the re-entered new password for verification."""
1096 # get the next waiting line of input
1097 input_data = user.input_queue.pop(0)
1099 # hash the input and match it to storage
1100 if new_md5(user.account.get("name") + input_data).hexdigest() == user.account.get("passhash"):
1103 # the hashes matched, so go active
1104 if not user.replace_old_connections(): user.state = "main_utility"
1106 # go back to entering the new password as long as you haven't tried
1108 elif user.password_tries < universe.categories["internal"]["limits"].getint("password_tries") - 1:
1109 user.password_tries += 1
1110 user.error = "differs"
1111 user.state = "entering_new_password"
1113 # otherwise, sayonara
1115 user.send("$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)")
1116 user.account.destroy()
1117 user.state = "disconnecting"
1119 def handler_active(user):
1120 """Handle input for active users."""
1122 # get the next waiting line of input
1123 input_data = user.input_queue.pop(0)
1125 # split out the command (first word) and parameters (everything else)
1126 if input_data.find(" ") > 0:
1127 command_name, parameters = input_data.split(" ", 1)
1129 command_name = input_data
1132 # lowercase the command
1133 command_name = command_name.lower()
1135 # the command matches a command word for which we have data
1136 if command_name in universe.categories["command"]:
1137 command = universe.categories["command"][command_name]
1138 else: command = None
1140 # if it's allowed, do it
1141 if user.can_run(command): exec(command.get("action"))
1143 # otherwise, give an error
1144 elif command_name: command_error(user, input_data)
1146 def command_halt(user, parameters):
1147 """Halt the world."""
1149 # see if there's a message or use a generic one
1150 if parameters: message = "Halting: " + parameters
1151 else: message = "User " + user.account.get("name") + " halted the world."
1157 # set a flag to terminate the world
1158 universe.terminate_world = True
1160 def command_reload(user):
1161 """Reload all code modules, configs and data."""
1163 # let the user know and log
1164 user.send("Reloading all code modules, configs and data.")
1165 log("User " + user.account.get("name") + " reloaded the world.")
1167 # set a flag to reload
1168 universe.reload_modules = True
1170 def command_help(user, parameters):
1171 """List available commands and provide help for commands."""
1173 # did the user ask for help on a specific command word?
1176 # is the command word one for which we have data?
1177 if parameters in universe.categories["command"]:
1178 command = universe.categories["command"][parameters]
1179 else: command = None
1181 # only for allowed commands
1182 if user.can_run(command):
1184 # add a description if provided
1185 description = command.get("description")
1187 description = "(no short description provided)"
1188 if command.getboolean("administrative"): output = "$(red)"
1189 else: output = "$(grn)"
1190 output += parameters + "$(nrm) - " + description + "$(eol)$(eol)"
1192 # add the help text if provided
1193 help_text = command.get("help")
1195 help_text = "No help is provided for this command."
1198 # no data for the requested command word
1200 output = "That is not an available command."
1202 # no specific command word was indicated
1205 # give a sorted list of commands with descriptions if provided
1206 output = "These are the commands available to you:$(eol)$(eol)"
1207 sorted_commands = universe.categories["command"].keys()
1208 sorted_commands.sort()
1209 for item in sorted_commands:
1210 command = universe.categories["command"][item]
1211 if user.can_run(command):
1212 description = command.get("description")
1214 description = "(no short description provided)"
1215 if command.getboolean("administrative"): output += " $(red)"
1216 else: output += " $(grn)"
1217 output += item + "$(nrm) - " + description + "$(eol)"
1218 output += "$(eol)Enter \"help COMMAND\" for help on a command named \"COMMAND\"."
1220 # send the accumulated output to the user
1223 def command_say(user, parameters):
1224 """Speak to others in the same room."""
1226 # check for replacement macros
1227 if replace_macros(user, parameters, True) != parameters:
1228 user.send("You cannot speak $_(replacement macros).")
1230 # the user entered a message
1233 # get rid of quote marks on the ends of the message and
1234 # capitalize the first letter
1235 message = parameters.strip("\"'`").capitalize()
1237 # a dictionary of punctuation:action pairs
1239 for facet in universe.categories["internal"]["language"].facets():
1240 if facet.startswith("punctuation_"):
1241 action = facet.split("_")[1]
1242 for mark in universe.categories["internal"]["language"].getlist(facet):
1243 actions[mark] = action
1245 # match the punctuation used, if any, to an action
1246 default_punctuation = universe.categories["internal"]["language"].get("default_punctuation")
1247 action = actions[default_punctuation]
1248 for mark in actions.keys():
1249 if message.endswith(mark) and mark != default_punctuation:
1250 action = actions[mark]
1253 # if the action is default and there is no mark, add one
1254 if action == actions[default_punctuation] and not message.endswith(default_punctuation):
1255 message += default_punctuation
1257 # capitalize a list of words within the message
1258 capitalize_words = universe.categories["internal"]["language"].getlist("capitalize_words")
1259 for word in capitalize_words:
1260 message = message.replace(" " + word + " ", " " + word.capitalize() + " ")
1263 # TODO: we won't be using broadcast once there are actual rooms
1264 broadcast(user.avatar.get("name") + " " + action + "s, \"" + message + "\"")
1266 # there was no message
1268 user.send("What do you want to say?")
1270 def command_show(user, parameters):
1271 """Show program data."""
1273 if parameters.find(" ") < 1:
1274 if parameters == "time":
1275 message = universe.categories["internal"]["counters"].get("elapsed") + " increments elapsed since the world was created."
1276 elif parameters == "categories":
1277 message = "These are the element categories:$(eol)"
1278 categories = universe.categories.keys()
1280 for category in categories: message += "$(eol) $(grn)" + category + "$(nrm)"
1281 elif parameters == "files":
1282 message = "These are the current files containing the universe:$(eol)"
1283 filenames = universe.files.keys()
1285 for filename in filenames: message += "$(eol) $(grn)" + filename + "$(nrm)"
1288 arguments = parameters.split()
1289 if arguments[0] == "category":
1290 if arguments[1] in universe.categories:
1291 message = "These are the elements in the \"" + arguments[1] + "\" category:$(eol)"
1292 elements = universe.categories[arguments[1]].keys()
1294 for element in elements:
1295 message += "$(eol) $(grn)" + universe.categories[arguments[1]][element].key + "$(nrm)"
1296 elif arguments[0] == "element":
1297 if arguments[1] in universe.contents:
1298 message = "These are the properties of the \"" + arguments[1] + "\" element:$(eol)"
1299 element = universe.contents[arguments[1]]
1300 facets = element.facets()
1302 for facet in facets:
1303 message += "$(eol) $(grn)" + facet + ": $(red)" + escape_macros(element.get(facet)) + "$(nrm)"
1305 if parameters: message = "I don't know what \"" + parameters + "\" is."
1306 else: message = "What do you want to show?"
1309 def command_create(user, parameters):
1310 """Create an element if it does not exist."""
1311 if not parameters: message = "You must at least specify an element to create."
1313 arguments = parameters.split()
1314 if len(arguments) == 1: arguments.append("")
1315 if len(arguments) == 2:
1316 element, filename = arguments
1317 if element in universe.contents: message = "The \"" + element + "\" element already exists."
1319 message = "You create \"" + element + "\" within the universe."
1320 logline = user.account.get("name") + " created an element: " + element
1322 logline += " in file " + filename
1323 if filename not in universe.files:
1324 message += " Warning: \"" + filename + "\" is not yet included in any other file and will not be read on startup unless this is remedied."
1325 Element(element, universe, filename)
1327 elif len(arguments) > 2: message = "You can only specify an element and a filename."
1330 def command_destroy(user, parameters):
1331 """Destroy an element if it exists."""
1332 if not parameters: message = "You must specify an element to destroy."
1334 if parameters not in universe.contents: message = "The \"" + parameters + "\" element does not exist."
1336 universe.contents[parameters].destroy()
1337 message = "You destroy \"" + parameters + "\" within the universe."
1338 log(user.account.get("name") + " destroyed an element: " + parameters)
1341 def command_set(user, parameters):
1342 """Set a facet of an element."""
1343 if not parameters: message = "You must specify an element, a facet and a value."
1345 arguments = parameters.split(" ", 2)
1346 if len(arguments) == 1: message = "What facet of element \"" + arguments[0] + "\" would you like to set?"
1347 elif len(arguments) == 2: message = "What value would you like to set for the \"" + arguments[1] + "\" facet of the \"" + arguments[0] + "\" element?"
1349 element, facet, value = arguments
1350 if element not in universe.contents: message = "The \"" + element + "\" element does not exist."
1352 universe.contents[element].set(facet, value)
1353 message = "You have successfully (re)set the \"" + facet + "\" facet of element \"" + element + "\". Try \"show element " + element + "\" for verification."
1356 def command_delete(user, parameters):
1357 """Delete a facet from an element."""
1358 if not parameters: message = "You must specify an element and a facet."
1360 arguments = parameters.split(" ")
1361 if len(arguments) == 1: message = "What facet of element \"" + arguments[0] + "\" would you like to delete?"
1362 elif len(arguments) != 2: message = "You may only specify an element and a facet."
1364 element, facet = arguments
1365 if element not in universe.contents: message = "The \"" + element + "\" element does not exist."
1366 elif facet not in universe.contents[element].facets(): message = "The \"" + element + "\" element has no \"" + facet + "\" facet."
1368 universe.contents[element].delete(facet)
1369 message = "You have successfully deleted the \"" + facet + "\" facet of element \"" + element + "\". Try \"show element " + element + "\" for verification."
1372 def command_error(user, input_data):
1373 """Generic error for an unrecognized command word."""
1375 # 90% of the time use a generic error
1377 message = "I'm not sure what \"" + input_data + "\" means..."
1379 # 10% of the time use the classic diku error
1381 message = "Arglebargle, glop-glyf!?!"
1383 # send the error message
1386 # if there is no universe, create an empty one
1387 if not "universe" in locals(): universe = Universe()