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
13 from socket import AF_INET, SO_REUSEADDR, SOCK_STREAM, SOL_SOCKET, socket
14 from stat import S_IMODE, ST_MODE
15 from syslog import LOG_PID, LOG_INFO, LOG_DAEMON, closelog, openlog, syslog
16 from telnetlib import DO, DONT, ECHO, EOR, GA, IAC, LINEMODE, SB, SE, SGA, WILL, WONT
17 from time import asctime, sleep
20 """An element of the universe."""
21 def __init__(self, key, universe, origin=""):
22 """Default values for the in-memory element variables."""
26 if self.key.find(":") > 0:
27 self.category, self.subkey = self.key.split(":", 1)
29 self.category = "other"
30 self.subkey = self.key
31 if not self.category in universe.categories: self.category = "other"
32 universe.categories[self.category][self.subkey] = self
34 if not self.origin: self.origin = universe.default_origins[self.category]
35 if not isabs(self.origin):
36 self.origin = abspath(self.origin)
37 universe.contents[self.key] = self
38 if not self.origin in universe.files:
39 DataFile(self.origin, universe)
40 if not universe.files[self.origin].data.has_section(self.key):
41 universe.files[self.origin].data.add_section(self.key)
43 """Remove an element from the universe and destroy it."""
44 log("Destroying: " + self.key + ".")
45 universe.files[self.origin].data.remove_section(self.key)
46 del universe.categories[self.category][self.subkey]
47 del universe.contents[self.key]
49 def delete(self, facet):
50 """Delete a facet from the element."""
51 if universe.files[self.origin].data.has_option(self.key, facet):
52 universe.files[self.origin].data.remove_option(self.key, facet)
54 """Return a list of non-inherited facets for this element."""
55 return universe.files[self.origin].data.options(self.key)
56 def has_facet(self, facet):
57 """Return whether the non-inherited facet exists."""
58 return facet in self.facets()
59 def remove_facet(self, facet):
60 """Remove a facet from the element."""
61 if self.has_facet(facet): universe.files[self.origin].data.remove_option(self.key, facet)
63 """Return a list of the element's inheritance lineage."""
64 if self.has_facet("inherit"):
65 ancestry = self.getlist("inherit")
66 for parent in ancestry[:]:
67 ancestors = universe.contents[parent].ancestry()
68 for ancestor in ancestors:
69 if ancestor not in ancestry: ancestry.append(ancestor)
72 def get(self, facet, default=None):
73 """Retrieve values."""
74 if default is None: default = ""
75 if universe.files[self.origin].data.has_option(self.key, facet):
76 return universe.files[self.origin].data.get(self.key, facet)
77 elif self.has_facet("inherit"):
78 for ancestor in self.ancestry():
79 if universe.contents[ancestor].has_facet(facet):
80 return universe.contents[ancestor].get(facet)
82 def getboolean(self, facet, default=None):
83 """Retrieve values as boolean type."""
84 if default is None: default=False
85 if universe.files[self.origin].data.has_option(self.key, facet):
86 return universe.files[self.origin].data.getboolean(self.key, facet)
87 elif self.has_facet("inherit"):
88 for ancestor in self.ancestry():
89 if universe.contents[ancestor].has_facet(facet):
90 return universe.contents[ancestor].getboolean(facet)
92 def getint(self, facet, default=None):
93 """Return values as int/long type."""
94 if default is None: default = 0
95 if universe.files[self.origin].data.has_option(self.key, facet):
96 return universe.files[self.origin].data.getint(self.key, facet)
97 elif self.has_facet("inherit"):
98 for ancestor in self.ancestry():
99 if universe.contents[ancestor].has_facet(facet):
100 return universe.contents[ancestor].getint(facet)
102 def getfloat(self, facet, default=None):
103 """Return values as float type."""
104 if default is None: default = 0.0
105 if universe.files[self.origin].data.has_option(self.key, facet):
106 return universe.files[self.origin].data.getfloat(self.key, facet)
107 elif self.has_facet("inherit"):
108 for ancestor in self.ancestry():
109 if universe.contents[ancestor].has_facet(facet):
110 return universe.contents[ancestor].getfloat(facet)
112 def getlist(self, facet, default=None):
113 """Return values as list type."""
114 if default is None: default = []
115 value = self.get(facet)
116 if value: return makelist(value)
118 def getdict(self, facet, default=None):
119 """Return values as dict type."""
120 if default is None: default = {}
121 value = self.get(facet)
122 if value: return makedict(value)
124 def set(self, facet, value):
126 if type(value) is long: value = str(value)
127 elif not type(value) is str: value = repr(value)
128 universe.files[self.origin].data.set(self.key, facet, value)
129 def append(self, facet, value):
130 """Append value tp a list."""
131 if type(value) is long: value = str(value)
132 elif not type(value) is str: value = repr(value)
133 newlist = self.getlist(facet)
134 newlist.append(value)
135 self.set(facet, newlist)
136 def send(self, message, eol="$(eol)"):
137 """Convenience method to pass messages to an owner."""
138 if self.owner: self.owner.send(message, eol)
139 def go_to(self, location):
140 """Relocate the element to a specific location."""
141 current = self.get("location")
142 if current and current in universe.contents[current].contents:
143 del universe.contents[current].contents[self.key]
144 if location in universe.contents: self.set("location", location)
145 universe.contents[location].contents[self.key] = self
146 self.look_at(location)
148 """Relocate the element to its default location."""
149 self.go_to(self.get("default_location"))
150 def move_direction(self, direction):
151 """Relocate the element in a specified direction."""
152 self.go_to(universe.contents[self.get("location")].link_neighbor(direction))
153 def look_at(self, key):
154 """Show an element to another element."""
156 element = universe.contents[key]
158 name = element.get("name")
159 if name: message += "$(cyn)" + name + "$(nrm)$(eol)"
160 description = element.get("description")
161 if description: message += description + "$(eol)"
162 portal_list = element.portals().keys()
165 message += "$(cyn)[ Exits: " + ", ".join(portal_list) + " ]$(nrm)$(eol)"
166 for element in universe.contents[self.get("location")].contents.values():
167 if element.getboolean("is_actor") and element is not self:
168 message += "$(yel)" + element.get("name") + " is here.$(nrm)$(eol)"
171 """Map the portal directions for a room to neighbors."""
173 if match("""^location:-?\d+,-?\d+,-?\d+$""", self.key):
174 coordinates = [(int(x)) for x in self.key.split(":")[1].split(",")]
183 for portal in self.getlist("gridlinks"):
184 adjacent = map(lambda c,o: c+o, coordinates, offsets[portal])
185 neighbor = "location:" + ",".join([(str(x)) for x in adjacent])
186 if neighbor in universe.contents: portals[portal] = neighbor
187 for facet in self.facets():
188 if facet.startswith("link_"):
189 neighbor = self.get(facet)
190 if neighbor in universe.contents:
191 portal = facet.split("_")[1]
192 portals[portal] = neighbor
194 def link_neighbor(self, direction):
195 """Return the element linked in a given direction."""
196 portals = self.portals()
197 if direction in portals: return portals[direction]
198 def echo_to_location(self, message):
199 """Show a message to other elements in the current location."""
200 for element in universe.contents[self.get("location")].contents.values():
201 if element is not self: element.send(message)
204 """A file containing universe elements."""
205 def __init__(self, filename, universe):
206 self.data = RawConfigParser()
207 if access(filename, R_OK): self.data.read(filename)
208 self.filename = filename
209 universe.files[filename] = self
210 if self.data.has_option("__control__", "include_files"):
211 includes = makelist(self.data.get("__control__", "include_files"))
213 if self.data.has_option("__control__", "default_files"):
214 origins = makedict(self.data.get("__control__", "default_files"))
215 for key in origins.keys():
216 if not key in includes: includes.append(key)
217 universe.default_origins[key] = origins[key]
218 if not key in universe.categories:
219 universe.categories[key] = {}
220 if self.data.has_option("__control__", "private_files"):
221 for item in makelist(self.data.get("__control__", "private_files")):
222 if not item in includes: includes.append(item)
223 if not item in universe.private_files:
225 item = path_join(dirname(filename), item)
226 universe.private_files.append(item)
227 for section in self.data.sections():
228 if section != "__control__":
229 Element(section, universe, filename)
230 for include_file in includes:
231 if not isabs(include_file):
232 include_file = path_join(dirname(filename), include_file)
233 DataFile(include_file, universe)
235 """Write the data, if necessary."""
237 # when there is content or the file exists, but is not read-only
238 if ( self.data.sections() or exists(self.filename) ) and not ( self.data.has_option("__control__", "read_only") and self.data.getboolean("__control__", "read_only") ):
240 # make parent directories if necessary
241 if not exists(dirname(self.filename)):
242 makedirs(dirname(self.filename))
245 file_descriptor = file(self.filename, "w")
247 # if it's marked private, chmod it appropriately
248 if self.filename in universe.private_files and oct(S_IMODE(stat(self.filename)[ST_MODE])) != 0600:
249 chmod(self.filename, 0600)
251 # write it back sorted, instead of using ConfigParser
252 sections = self.data.sections()
254 for section in sections:
255 file_descriptor.write("[" + section + "]\n")
256 options = self.data.options(section)
258 for option in options:
259 file_descriptor.write(option + " = " + self.data.get(section, option) + "\n")
260 file_descriptor.write("\n")
262 # flush and close the file
263 file_descriptor.flush()
264 file_descriptor.close()
268 def __init__(self, filename=""):
269 """Initialize the universe."""
272 self.default_origins = {}
274 self.private_files = []
276 self.terminate_world = False
277 self.reload_modules = False
279 possible_filenames = [
285 "/usr/local/mudpy/mudpy.conf",
286 "/usr/local/mudpy/etc/mudpy.conf",
287 "/etc/mudpy/mudpy.conf",
290 for filename in possible_filenames:
291 if access(filename, R_OK): break
292 if not isabs(filename):
293 filename = abspath(filename)
294 DataFile(filename, self)
296 """Save the universe to persistent storage."""
297 for key in self.files: self.files[key].save()
299 def initialize_server_socket(self):
300 """Create and open the listening socket."""
302 # create a new ipv4 stream-type socket object
303 self.listening_socket = socket(AF_INET, SOCK_STREAM)
305 # set the socket options to allow existing open ones to be
306 # reused (fixes a bug where the server can't bind for a minute
307 # when restarting on linux systems)
308 self.listening_socket.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
310 # bind the socket to to our desired server ipa and port
311 self.listening_socket.bind((self.categories["internal"]["network"].get("host"), self.categories["internal"]["network"].getint("port")))
313 # disable blocking so we can proceed whether or not we can
315 self.listening_socket.setblocking(0)
317 # start listening on the socket
318 self.listening_socket.listen(1)
320 # note that we're now ready for user connections
321 log("Waiting for connection(s)...")
324 """This is a connected user."""
327 """Default values for the in-memory user variables."""
329 self.last_address = ""
330 self.connection = None
331 self.authenticated = False
332 self.password_tries = 0
333 self.state = "initial"
334 self.menu_seen = False
336 self.input_queue = []
337 self.output_queue = []
338 self.partial_input = ""
340 self.terminator = IAC+GA
341 self.negotiation_pause = 0
346 """Log, close the connection and remove."""
347 if self.account: name = self.account.get("name")
349 if name: message = "User " + name
350 else: message = "An unnamed user"
351 message += " logged out."
353 self.deactivate_avatar()
354 self.connection.close()
358 """Save, load a new user and relocate the connection."""
360 # get out of the list
363 # create a new user object
366 # set everything else equivalent
385 exec("new_user." + attribute + " = self." + attribute)
388 universe.userlist.append(new_user)
390 # get rid of the old user object
393 def replace_old_connections(self):
394 """Disconnect active users with the same name."""
396 # the default return value
399 # iterate over each user in the list
400 for old_user in universe.userlist:
402 # the name is the same but it's not us
403 if old_user.account.get("name") == self.account.get("name") and old_user is not self:
406 log("User " + self.account.get("name") + " reconnected--closing old connection to " + old_user.address + ".")
407 old_user.send("$(eol)$(red)New connection from " + self.address + ". Terminating old connection...$(nrm)$(eol)", flush=True, add_prompt=False)
409 # close the old connection
410 old_user.connection.close()
412 # replace the old connection with this one
413 old_user.send("$(eol)$(red)Taking over old connection from " + old_user.address + ".$(nrm)")
414 old_user.connection = self.connection
415 old_user.last_address = old_user.address
416 old_user.address = self.address
417 old_user.echoing = self.echoing
419 # take this one out of the list and delete
425 # true if an old connection was replaced, false if not
428 def authenticate(self):
429 """Flag the user as authenticated and disconnect duplicates."""
430 if not self.state is "authenticated":
431 log("User " + self.account.get("name") + " logged in.")
432 self.authenticated = True
433 if self.account.subkey in universe.categories["internal"]["limits"].getlist("default_admins"):
434 self.account.set("administrator", "True")
437 """Send the user their current menu."""
438 if not self.menu_seen:
439 self.menu_choices = get_menu_choices(self)
440 self.send(get_menu(self.state, self.error, self.echoing, self.terminator, self.menu_choices), "")
441 self.menu_seen = True
443 self.adjust_echoing()
445 def adjust_echoing(self):
446 """Adjust echoing to match state menu requirements."""
447 if self.echoing and not menu_echo_on(self.state): self.echoing = False
448 elif not self.echoing and menu_echo_on(self.state): self.echoing = True
451 """Remove a user from the list of connected users."""
452 universe.userlist.remove(self)
454 def send(self, output, eol="$(eol)", raw=False, flush=False, add_prompt=True):
455 """Send arbitrary text to a connected user."""
457 # unless raw mode is on, clean it up all nice and pretty
460 # strip extra $(eol) off if present
461 while output.startswith("$(eol)"): output = output[6:]
462 while output.endswith("$(eol)"): output = output[:-6]
464 # we'll take out GA or EOR and add them back on the end
465 if output.endswith(IAC+GA) or output.endswith(IAC+EOR):
468 else: terminate = False
470 # start with a newline, append the message, then end
471 # with the optional eol string passed to this function
472 # and the ansi escape to return to normal text
473 output = "$(eol)" + output + eol + chr(27) + "[0m"
475 # tack on a prompt if active
476 if self.state == "active" and add_prompt: output += "$(eol)> "
478 # find and replace macros in the output
479 output = replace_macros(self, output)
481 # wrap the text at 80 characters
482 output = wrap_ansi_text(output, 80)
484 # tack the terminator back on
485 if terminate: output += self.terminator
487 # drop the output into the user's output queue
488 self.output_queue.append(output)
490 # if this is urgent, flush all pending output
491 if flush: self.flush()
494 """All the things to do to the user per increment."""
496 # if the world is terminating, disconnect
497 if universe.terminate_world:
498 self.state = "disconnecting"
499 self.menu_seen = False
501 # if output is paused, decrement the counter
502 if self.state == "initial":
503 if self.negotiation_pause: self.negotiation_pause -= 1
504 else: self.state = "entering_account_name"
506 # show the user a menu as needed
507 elif not self.state == "active": self.show_menu()
509 # flush any pending output in teh queue
512 # disconnect users with the appropriate state
513 if self.state == "disconnecting": self.quit()
515 # check for input and add it to the queue
518 # there is input waiting in the queue
519 if self.input_queue: handle_user_input(self)
522 """Try to send the last item in the queue and remove it."""
523 if self.output_queue:
525 self.connection.send(self.output_queue[0])
526 del self.output_queue[0]
531 def enqueue_input(self):
532 """Process and enqueue any new input."""
534 # check for some input
536 input_data = self.connection.recv(1024)
543 # tack this on to any previous partial
544 self.partial_input += input_data
546 # reply to and remove any IAC negotiation codes
547 self.negotiate_telnet_options()
549 # separate multiple input lines
550 new_input_lines = self.partial_input.split("\n")
552 # if input doesn't end in a newline, replace the
553 # held partial input with the last line of it
554 if not self.partial_input.endswith("\n"):
555 self.partial_input = new_input_lines.pop()
557 # otherwise, chop off the extra null input and reset
558 # the held partial input
560 new_input_lines.pop()
561 self.partial_input = ""
563 # iterate over the remaining lines
564 for line in new_input_lines:
566 # remove a trailing carriage return
567 if line.endswith("\r"): line = line.rstrip("\r")
569 # log non-printable characters remaining
570 removed = filter(lambda x: (x < " " or x > "~"), line)
572 logline = "Non-printable characters from "
573 if self.account and self.account.get("name"): logline += self.account.get("name") + ": "
574 else: logline += "unknown user: "
575 logline += repr(removed)
578 # filter out non-printables
579 line = filter(lambda x: " " <= x <= "~", line)
581 # strip off extra whitespace
584 # put on the end of the queue
585 self.input_queue.append(line)
587 def negotiate_telnet_options(self):
588 """Reply to/remove partial_input telnet negotiation options."""
590 # start at the begining of the input
593 # make a local copy to play with
594 text = self.partial_input
596 # as long as we haven't checked it all
597 while position < len(text):
599 # jump to the first IAC you find
600 position = text.find(IAC, position)
602 # if there wasn't an IAC in the input, skip to the end
603 if position < 0: position = len(text)
605 # replace a double (literal) IAC if there's an LF later
606 elif len(text) > position+1 and text[position+1] == IAC:
607 if text.find("\n", position) > 0: text = text.replace(IAC+IAC, IAC)
611 # this must be an option negotiation
612 elif len(text) > position+2 and text[position+1] in (DO, DONT, WILL, WONT):
614 negotiation = text[position+1:position+3]
616 # if we turned echo off, ignore the confirmation
617 if not self.echoing and negotiation == DO+ECHO: pass
620 elif negotiation == WILL+LINEMODE: self.send(IAC+DO+LINEMODE, raw=True)
622 # if the client likes EOR instead of GA, make a note of it
623 elif negotiation == DO+EOR: self.terminator = IAC+EOR
624 elif negotiation == DONT+EOR and self.terminator == IAC+EOR:
625 self.terminator = IAC+GA
627 # if the client doesn't want GA, oblige
628 elif negotiation == DO+SGA and self.terminator == IAC+GA:
630 self.send(IAC+WILL+SGA, raw=True)
632 # we don't want to allow anything else
633 elif text[position+1] == DO: self.send(IAC+WONT+text[position+2], raw=True)
634 elif text[position+1] == WILL: self.send(IAC+DONT+text[position+2], raw=True)
636 # strip the negotiation from the input
637 text = text.replace(text[position:position+3], "")
639 # get rid of IAC SB .* IAC SE
640 elif len(text) > position+4 and text[position:position+2] == IAC+SB:
641 end_subnegotiation = text.find(IAC+SE, position)
642 if end_subnegotiation > 0: text = text[:position] + text[end_subnegotiation+2:]
645 # otherwise, strip out a two-byte IAC command
646 elif len(text) > position+2: text = text.replace(text[position:position+2], "")
648 # and this means we got the begining of an IAC
651 # replace the input with our cleaned-up text
652 self.partial_input = text
654 def can_run(self, command):
655 """Check if the user can run this command object."""
657 # has to be in the commands category
658 if command not in universe.categories["command"].values(): result = False
660 # administrators can run any command
661 elif self.account.getboolean("administrator"): result = True
663 # everyone can run non-administrative commands
664 elif not command.getboolean("administrative"): result = True
666 # otherwise the command cannot be run by this user
669 # pass back the result
672 def new_avatar(self):
673 """Instantiate a new, unconfigured avatar for this user."""
675 while "avatar:" + self.account.get("name") + ":" + str(counter) in universe.categories["actor"].keys(): counter += 1
676 self.avatar = Element("actor:avatar:" + self.account.get("name") + ":" + str(counter), universe)
677 self.avatar.append("inherit", "template:actor")
678 self.account.append("avatars", self.avatar.key)
680 def delete_avatar(self, avatar):
681 """Remove an avatar from the world and from the user's list."""
682 if self.avatar is universe.contents[avatar]: self.avatar = None
683 universe.contents[avatar].destroy()
684 avatars = self.account.getlist("avatars")
685 avatars.remove(avatar)
686 self.account.set("avatars", avatars)
688 def activate_avatar_by_index(self, index):
689 """Enter the world with a particular indexed avatar."""
690 self.avatar = universe.contents[self.account.getlist("avatars")[index]]
691 self.avatar.owner = self
692 self.state = "active"
693 self.avatar.go_home()
695 def deactivate_avatar(self):
696 """Have the active avatar leave the world."""
698 current = self.avatar.get("location")
699 self.avatar.set("default_location", current)
700 del universe.contents[current].contents[self.avatar.key]
701 self.avatar.remove_facet("location")
702 self.avatar.owner = None
706 """Destroy the user and associated avatars."""
707 for avatar in self.account.getlist("avatars"): self.delete_avatar(avatar)
708 self.account.destroy()
710 def list_avatar_names(self):
711 """List names of assigned avatars."""
712 return [ universe.contents[avatar].get("name") for avatar in self.account.getlist("avatars") ]
715 """Turn string into list type."""
716 if value[0] + value[-1] == "[]": return eval(value)
717 else: return [ value ]
720 """Turn string into dict type."""
721 if value[0] + value[-1] == "{}": return eval(value)
722 elif value.find(":") > 0: return eval("{" + value + "}")
723 else: return { value: None }
725 def broadcast(message):
726 """Send a message to all connected users."""
727 for each_user in universe.userlist: each_user.send("$(eol)" + message)
732 # the time in posix log timestamp format
733 timestamp = asctime()[4:19]
735 # send the timestamp and message to standard output
736 print(timestamp + " " + message)
738 # send the message to the system log
739 openlog("mudpy", LOG_PID, LOG_INFO | LOG_DAEMON)
743 def wrap_ansi_text(text, width):
744 """Wrap text with arbitrary width while ignoring ANSI colors."""
746 # the current position in the entire text string, including all
747 # characters, printable or otherwise
748 absolute_position = 0
750 # the current text position relative to the begining of the line,
751 # ignoring color escape sequences
752 relative_position = 0
754 # whether the current character is part of a color escape sequence
757 # iterate over each character from the begining of the text
758 for each_character in text:
760 # the current character is the escape character
761 if each_character == chr(27):
764 # the current character is within an escape sequence
767 # the current character is m, which terminates the
768 # current escape sequence
769 if each_character == "m":
772 # the current character is a newline, so reset the relative
773 # position (start a new line)
774 elif each_character == "\n":
775 relative_position = 0
777 # the current character meets the requested maximum line width,
778 # so we need to backtrack and find a space at which to wrap
779 elif relative_position == width:
781 # distance of the current character examined from the
785 # count backwards until we find a space
786 while text[absolute_position - wrap_offset] != " ":
789 # insert an eol in place of the space
790 text = text[:absolute_position - wrap_offset] + "\r\n" + text[absolute_position - wrap_offset + 1:]
792 # increase the absolute position because an eol is two
793 # characters but the space it replaced was only one
794 absolute_position += 1
796 # now we're at the begining of a new line, plus the
797 # number of characters wrapped from the previous line
798 relative_position = wrap_offset
800 # as long as the character is not a carriage return and the
801 # other above conditions haven't been met, count it as a
802 # printable character
803 elif each_character != "\r":
804 relative_position += 1
806 # increase the absolute position for every character
807 absolute_position += 1
809 # return the newly-wrapped text
812 def weighted_choice(data):
813 """Takes a dict weighted by value and returns a random key."""
815 # this will hold our expanded list of keys from the data
818 # create thee expanded list of keys
819 for key in data.keys():
820 for count in range(data[key]):
823 # return one at random
824 return choice(expanded)
827 """Returns a random character name."""
829 # the vowels and consonants needed to create romaji syllables
830 vowels = [ "a", "i", "u", "e", "o" ]
831 consonants = ["'", "k", "z", "s", "sh", "z", "j", "t", "ch", "ts", "d", "n", "h", "f", "m", "y", "r", "w" ]
833 # this dict will hold our weighted list of syllables
836 # generate the list with an even weighting
837 for consonant in consonants:
839 syllables[consonant + vowel] = 1
841 # we'll build the name into this string
844 # create a name of random length from the syllables
845 for syllable in range(randrange(2, 6)):
846 name += weighted_choice(syllables)
848 # strip any leading quotemark, capitalize and return the name
849 return name.strip("'").capitalize()
851 def replace_macros(user, text, is_input=False):
852 """Replaces macros in text output."""
857 # third person pronouns
859 "female": { "obj": "her", "pos": "hers", "sub": "she" },
860 "male": { "obj": "him", "pos": "his", "sub": "he" },
861 "neuter": { "obj": "it", "pos": "its", "sub": "it" }
864 # a dict of replacement macros
867 "$(bld)": chr(27) + "[1m",
868 "$(nrm)": chr(27) + "[0m",
869 "$(blk)": chr(27) + "[30m",
870 "$(blu)": chr(27) + "[34m",
871 "$(cyn)": chr(27) + "[36m",
872 "$(grn)": chr(27) + "[32m",
873 "$(mgt)": chr(27) + "[35m",
874 "$(red)": chr(27) + "[31m",
875 "$(yel)": chr(27) + "[33m",
878 # add dynamic macros where possible
880 account_name = user.account.get("name")
882 macros["$(account)"] = account_name
884 avatar_gender = user.avatar.get("gender")
886 macros["$(tpop)"] = pronouns[avatar_gender]["obj"]
887 macros["$(tppp)"] = pronouns[avatar_gender]["pos"]
888 macros["$(tpsp)"] = pronouns[avatar_gender]["sub"]
890 # find and replace per the macros dict
891 macro_start = text.find("$(")
892 if macro_start == -1: break
893 macro_end = text.find(")", macro_start) + 1
894 macro = text[macro_start:macro_end]
895 if macro in macros.keys():
896 text = text.replace(macro, macros[macro])
898 # if we get here, log and replace it with null
900 text = text.replace(macro, "")
902 log("Unexpected replacement macro " + macro + " encountered.")
904 # replace the look-like-a-macro sequence
905 text = text.replace("$_(", "$(")
909 def escape_macros(text):
910 """Escapes replacement macros in text."""
911 return text.replace("$(", "$_(")
913 def check_time(frequency):
914 """Check for a factor of the current increment count."""
915 if type(frequency) is str:
916 frequency = universe.categories["internal"]["time"].getint(frequency)
917 if not "counters" in universe.categories["internal"]:
918 Element("internal:counters", universe)
919 return not universe.categories["internal"]["counters"].getint("elapsed") % frequency
922 """The things which should happen on each pulse, aside from reloads."""
924 # open the listening socket if it hasn't been already
925 if not hasattr(universe, "listening_socket"):
926 universe.initialize_server_socket()
928 # assign a user if a new connection is waiting
929 user = check_for_connection(universe.listening_socket)
930 if user: universe.userlist.append(user)
932 # iterate over the connected users
933 for user in universe.userlist: user.pulse()
935 # update the log every now and then
936 if check_time("frequency_log"):
937 log(str(len(universe.userlist)) + " connection(s)")
939 # periodically save everything
940 if check_time("frequency_save"):
943 # pause for a configurable amount of time (decimal seconds)
944 sleep(universe.categories["internal"]["time"].getfloat("increment"))
946 # increment the elapsed increment counter
947 universe.categories["internal"]["counters"].set("elapsed", universe.categories["internal"]["counters"].getint("elapsed") + 1)
950 """Reload data into new persistent objects."""
951 for user in universe.userlist[:]: user.reload()
953 def check_for_connection(listening_socket):
954 """Check for a waiting connection and return a new user object."""
956 # try to accept a new connection
958 connection, address = listening_socket.accept()
962 # note that we got one
963 log("Connection from " + address[0])
965 # disable blocking so we can proceed whether or not we can send/receive
966 connection.setblocking(0)
968 # create a new user object
971 # associate this connection with it
972 user.connection = connection
974 # set the user's ipa from the connection's ipa
975 user.address = address[0]
977 # let the client know we WILL EOR
978 user.send(IAC+WILL+EOR, raw=True)
979 user.negotiation_pause = 2
981 # return the new user object
984 def get_menu(state, error=None, echoing=True, terminator="", choices=None):
985 """Show the correct menu text to a user."""
987 # make sure we don't reuse a mutable sequence by default
988 if choices is None: choices = {}
990 # begin with a telnet echo command sequence if needed
991 message = get_echo_sequence(state, echoing)
993 # get the description or error text
994 message += get_menu_description(state, error)
996 # get menu choices for the current state
997 message += get_formatted_menu_choices(state, choices)
999 # try to get a prompt, if it was defined
1000 message += get_menu_prompt(state)
1002 # throw in the default choice, if it exists
1003 message += get_formatted_default_menu_choice(state)
1005 # display a message indicating if echo is off
1006 message += get_echo_message(state)
1008 # tack on EOR or GA to indicate the prompt will not be followed by CRLF
1009 message += terminator
1011 # return the assembly of various strings defined above
1014 def menu_echo_on(state):
1015 """True if echo is on, false if it is off."""
1016 return universe.categories["menu"][state].getboolean("echo", True)
1018 def get_echo_sequence(state, echoing):
1019 """Build the appropriate IAC WILL/WONT ECHO sequence as needed."""
1021 # if the user has echo on and the menu specifies it should be turned
1022 # off, send: iac + will + echo + null
1023 if echoing and not menu_echo_on(state): return IAC+WILL+ECHO
1025 # if echo is not set to off in the menu and the user curently has echo
1026 # off, send: iac + wont + echo + null
1027 elif not echoing and menu_echo_on(state): return IAC+WONT+ECHO
1029 # default is not to send an echo control sequence at all
1032 def get_echo_message(state):
1033 """Return a message indicating that echo is off."""
1034 if menu_echo_on(state): return ""
1035 else: return "(won't echo) "
1037 def get_default_menu_choice(state):
1038 """Return the default choice for a menu."""
1039 return universe.categories["menu"][state].get("default")
1041 def get_formatted_default_menu_choice(state):
1042 """Default menu choice foratted for inclusion in a prompt string."""
1043 default_choice = get_default_menu_choice(state)
1044 if default_choice: return "[$(red)" + default_choice + "$(nrm)] "
1047 def get_menu_description(state, error):
1048 """Get the description or error text."""
1050 # an error condition was raised by the handler
1053 # try to get an error message matching the condition
1055 description = universe.categories["menu"][state].get("error_" + error)
1056 if not description: description = "That is not a valid choice..."
1057 description = "$(red)" + description + "$(nrm)"
1059 # there was no error condition
1062 # try to get a menu description for the current state
1063 description = universe.categories["menu"][state].get("description")
1065 # return the description or error message
1066 if description: description += "$(eol)$(eol)"
1069 def get_menu_prompt(state):
1070 """Try to get a prompt, if it was defined."""
1071 prompt = universe.categories["menu"][state].get("prompt")
1072 if prompt: prompt += " "
1075 def get_menu_choices(user):
1076 """Return a dict of choice:meaning."""
1077 menu = universe.categories["menu"][user.state]
1078 create_choices = menu.get("create")
1079 if create_choices: choices = eval(create_choices)
1084 for facet in menu.facets():
1085 if facet.startswith("demand_") and not eval(universe.categories["menu"][user.state].get(facet)):
1086 ignores.append(facet.split("_", 2)[1])
1087 elif facet.startswith("create_"):
1088 creates[facet] = facet.split("_", 2)[1]
1089 elif facet.startswith("choice_"):
1090 options[facet] = facet.split("_", 2)[1]
1091 for facet in creates.keys():
1092 if not creates[facet] in ignores:
1093 choices[creates[facet]] = eval(menu.get(facet))
1094 for facet in options.keys():
1095 if not options[facet] in ignores:
1096 choices[options[facet]] = menu.get(facet)
1099 def get_formatted_menu_choices(state, choices):
1100 """Returns a formatted string of menu choices."""
1102 choice_keys = choices.keys()
1104 for choice in choice_keys:
1105 choice_output += " [$(red)" + choice + "$(nrm)] " + choices[choice] + "$(eol)"
1106 if choice_output: choice_output += "$(eol)"
1107 return choice_output
1109 def get_menu_branches(state):
1110 """Return a dict of choice:branch."""
1112 for facet in universe.categories["menu"][state].facets():
1113 if facet.startswith("branch_"):
1114 branches[facet.split("_", 2)[1]] = universe.categories["menu"][state].get(facet)
1117 def get_default_branch(state):
1118 """Return the default branch."""
1119 return universe.categories["menu"][state].get("branch")
1121 def get_choice_branch(user, choice):
1122 """Returns the new state matching the given choice."""
1123 branches = get_menu_branches(user.state)
1124 if choice in branches.keys(): return branches[choice]
1125 elif choice in user.menu_choices.keys(): return get_default_branch(user.state)
1128 def get_menu_actions(state):
1129 """Return a dict of choice:branch."""
1131 for facet in universe.categories["menu"][state].facets():
1132 if facet.startswith("action_"):
1133 actions[facet.split("_", 2)[1]] = universe.categories["menu"][state].get(facet)
1136 def get_default_action(state):
1137 """Return the default action."""
1138 return universe.categories["menu"][state].get("action")
1140 def get_choice_action(user, choice):
1141 """Run any indicated script for the given choice."""
1142 actions = get_menu_actions(user.state)
1143 if choice in actions.keys(): return actions[choice]
1144 elif choice in user.menu_choices.keys(): return get_default_action(user.state)
1147 def handle_user_input(user):
1148 """The main handler, branches to a state-specific handler."""
1150 # check to make sure the state is expected, then call that handler
1151 if "handler_" + user.state in globals():
1152 exec("handler_" + user.state + "(user)")
1154 generic_menu_handler(user)
1156 # since we got input, flag that the menu/prompt needs to be redisplayed
1157 user.menu_seen = False
1159 # if the user's client echo is off, send a blank line for aesthetics
1160 if not user.echoing: user.send("", "")
1162 def generic_menu_handler(user):
1163 """A generic menu choice handler."""
1165 # get a lower-case representation of the next line of input
1166 if user.input_queue:
1167 choice = user.input_queue.pop(0)
1168 if choice: choice = choice.lower()
1170 if not choice: choice = get_default_menu_choice(user.state)
1171 if choice in user.menu_choices:
1172 exec(get_choice_action(user, choice))
1173 new_state = get_choice_branch(user, choice)
1174 if new_state: user.state = new_state
1175 else: user.error = "default"
1177 def handler_entering_account_name(user):
1178 """Handle the login account name."""
1180 # get the next waiting line of input
1181 input_data = user.input_queue.pop(0)
1183 # did the user enter anything?
1186 # keep only the first word and convert to lower-case
1187 name = input_data.lower()
1189 # fail if there are non-alphanumeric characters
1190 if name != filter(lambda x: x>="0" and x<="9" or x>="a" and x<="z", name):
1191 user.error = "bad_name"
1193 # if that account exists, time to request a password
1194 elif name in universe.categories["account"]:
1195 user.account = universe.categories["account"][name]
1196 user.state = "checking_password"
1198 # otherwise, this could be a brand new user
1200 user.account = Element("account:" + name, universe)
1201 user.account.set("name", name)
1202 log("New user: " + name)
1203 user.state = "checking_new_account_name"
1205 # if the user entered nothing for a name, then buhbye
1207 user.state = "disconnecting"
1209 def handler_checking_password(user):
1210 """Handle the login account password."""
1212 # get the next waiting line of input
1213 input_data = user.input_queue.pop(0)
1215 # does the hashed input equal the stored hash?
1216 if new_md5(user.account.get("name") + input_data).hexdigest() == user.account.get("passhash"):
1218 # if so, set the username and load from cold storage
1219 if not user.replace_old_connections():
1221 user.state = "main_utility"
1223 # if at first your hashes don't match, try, try again
1224 elif user.password_tries < universe.categories["internal"]["limits"].getint("password_tries") - 1:
1225 user.password_tries += 1
1226 user.error = "incorrect"
1228 # we've exceeded the maximum number of password failures, so disconnect
1230 user.send("$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)")
1231 user.state = "disconnecting"
1233 def handler_entering_new_password(user):
1234 """Handle a new password entry."""
1236 # get the next waiting line of input
1237 input_data = user.input_queue.pop(0)
1239 # make sure the password is strong--at least one upper, one lower and
1240 # one digit, seven or more characters in length
1241 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)):
1243 # hash and store it, then move on to verification
1244 user.account.set("passhash", new_md5(user.account.get("name") + input_data).hexdigest())
1245 user.state = "verifying_new_password"
1247 # the password was weak, try again if you haven't tried too many times
1248 elif user.password_tries < universe.categories["internal"]["limits"].getint("password_tries") - 1:
1249 user.password_tries += 1
1252 # too many tries, so adios
1254 user.send("$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)")
1255 user.account.destroy()
1256 user.state = "disconnecting"
1258 def handler_verifying_new_password(user):
1259 """Handle the re-entered new password for verification."""
1261 # get the next waiting line of input
1262 input_data = user.input_queue.pop(0)
1264 # hash the input and match it to storage
1265 if new_md5(user.account.get("name") + input_data).hexdigest() == user.account.get("passhash"):
1268 # the hashes matched, so go active
1269 if not user.replace_old_connections(): user.state = "main_utility"
1271 # go back to entering the new password as long as you haven't tried
1273 elif user.password_tries < universe.categories["internal"]["limits"].getint("password_tries") - 1:
1274 user.password_tries += 1
1275 user.error = "differs"
1276 user.state = "entering_new_password"
1278 # otherwise, sayonara
1280 user.send("$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)")
1281 user.account.destroy()
1282 user.state = "disconnecting"
1284 def handler_active(user):
1285 """Handle input for active users."""
1287 # get the next waiting line of input
1288 input_data = user.input_queue.pop(0)
1290 # split out the command (first word) and parameters (everything else)
1291 if input_data.find(" ") > 0:
1292 command_name, parameters = input_data.split(" ", 1)
1294 command_name = input_data
1297 # lowercase the command
1298 command_name = command_name.lower()
1300 # the command matches a command word for which we have data
1301 if command_name in universe.categories["command"]:
1302 command = universe.categories["command"][command_name]
1303 else: command = None
1305 # if it's allowed, do it
1306 if user.can_run(command): exec(command.get("action"))
1308 # otherwise, give an error
1309 elif command_name: command_error(user, input_data)
1311 def command_halt(user, parameters):
1312 """Halt the world."""
1314 # see if there's a message or use a generic one
1315 if parameters: message = "Halting: " + parameters
1316 else: message = "User " + user.account.get("name") + " halted the world."
1322 # set a flag to terminate the world
1323 universe.terminate_world = True
1325 def command_reload(user):
1326 """Reload all code modules, configs and data."""
1328 # let the user know and log
1329 user.send("Reloading all code modules, configs and data.")
1330 log("User " + user.account.get("name") + " reloaded the world.")
1332 # set a flag to reload
1333 universe.reload_modules = True
1335 def command_quit(user):
1336 """Leave the world and go back to the main menu."""
1337 user.deactivate_avatar()
1338 user.state = "main_utility"
1340 def command_help(user, parameters):
1341 """List available commands and provide help for commands."""
1343 # did the user ask for help on a specific command word?
1346 # is the command word one for which we have data?
1347 if parameters in universe.categories["command"]:
1348 command = universe.categories["command"][parameters]
1349 else: command = None
1351 # only for allowed commands
1352 if user.can_run(command):
1354 # add a description if provided
1355 description = command.get("description")
1357 description = "(no short description provided)"
1358 if command.getboolean("administrative"): output = "$(red)"
1359 else: output = "$(grn)"
1360 output += parameters + "$(nrm) - " + description + "$(eol)$(eol)"
1362 # add the help text if provided
1363 help_text = command.get("help")
1365 help_text = "No help is provided for this command."
1368 # no data for the requested command word
1370 output = "That is not an available command."
1372 # no specific command word was indicated
1375 # give a sorted list of commands with descriptions if provided
1376 output = "These are the commands available to you:$(eol)$(eol)"
1377 sorted_commands = universe.categories["command"].keys()
1378 sorted_commands.sort()
1379 for item in sorted_commands:
1380 command = universe.categories["command"][item]
1381 if user.can_run(command):
1382 description = command.get("description")
1384 description = "(no short description provided)"
1385 if command.getboolean("administrative"): output += " $(red)"
1386 else: output += " $(grn)"
1387 output += item + "$(nrm) - " + description + "$(eol)"
1388 output += "$(eol)Enter \"help COMMAND\" for help on a command named \"COMMAND\"."
1390 # send the accumulated output to the user
1393 def command_move(user, parameters):
1394 """Move the avatar in a given direction."""
1395 if parameters in universe.contents[user.avatar.get("location")].portals():
1396 user.avatar.move_direction(parameters)
1397 else: user.send("You cannot go that way.")
1399 def command_say(user, parameters):
1400 """Speak to others in the same room."""
1402 # check for replacement macros
1403 if replace_macros(user, parameters, True) != parameters:
1404 user.send("You cannot speak $_(replacement macros).")
1406 # the user entered a message
1409 # get rid of quote marks on the ends of the message and
1410 # capitalize the first letter
1411 message = parameters.strip("\"'`").capitalize()
1413 # a dictionary of punctuation:action pairs
1415 for facet in universe.categories["internal"]["language"].facets():
1416 if facet.startswith("punctuation_"):
1417 action = facet.split("_")[1]
1418 for mark in universe.categories["internal"]["language"].getlist(facet):
1419 actions[mark] = action
1421 # match the punctuation used, if any, to an action
1422 default_punctuation = universe.categories["internal"]["language"].get("default_punctuation")
1423 action = actions[default_punctuation]
1424 for mark in actions.keys():
1425 if message.endswith(mark) and mark != default_punctuation:
1426 action = actions[mark]
1429 # if the action is default and there is no mark, add one
1430 if action == actions[default_punctuation] and not message.endswith(default_punctuation):
1431 message += default_punctuation
1433 # capitalize a list of words within the message
1434 capitalize_words = universe.categories["internal"]["language"].getlist("capitalize_words")
1435 for word in capitalize_words:
1436 message = message.replace(" " + word + " ", " " + word.capitalize() + " ")
1439 user.avatar.echo_to_location(user.avatar.get("name") + " " + action + "s, \"" + message + "\"")
1440 user.send("You " + action + ", \"" + message + "\"")
1442 # there was no message
1444 user.send("What do you want to say?")
1446 def command_show(user, parameters):
1447 """Show program data."""
1449 if parameters.find(" ") < 1:
1450 if parameters == "time":
1451 message = universe.categories["internal"]["counters"].get("elapsed") + " increments elapsed since the world was created."
1452 elif parameters == "categories":
1453 message = "These are the element categories:$(eol)"
1454 categories = universe.categories.keys()
1456 for category in categories: message += "$(eol) $(grn)" + category + "$(nrm)"
1457 elif parameters == "files":
1458 message = "These are the current files containing the universe:$(eol)"
1459 filenames = universe.files.keys()
1461 for filename in filenames: message += "$(eol) $(grn)" + filename + "$(nrm)"
1464 arguments = parameters.split()
1465 if arguments[0] == "category":
1466 if arguments[1] in universe.categories:
1467 message = "These are the elements in the \"" + arguments[1] + "\" category:$(eol)"
1468 elements = universe.categories[arguments[1]].keys()
1470 for element in elements:
1471 message += "$(eol) $(grn)" + universe.categories[arguments[1]][element].key + "$(nrm)"
1472 elif arguments[0] == "element":
1473 if arguments[1] in universe.contents:
1474 message = "These are the properties of the \"" + arguments[1] + "\" element:$(eol)"
1475 element = universe.contents[arguments[1]]
1476 facets = element.facets()
1478 for facet in facets:
1479 message += "$(eol) $(grn)" + facet + ": $(red)" + escape_macros(element.get(facet)) + "$(nrm)"
1480 elif arguments[0] == "result":
1481 if len(arguments) > 1:
1483 message = repr(eval(" ".join(arguments[1:])))
1485 message = "Your expression raised an exception!"
1487 if parameters: message = "I don't know what \"" + parameters + "\" is."
1488 else: message = "What do you want to show?"
1491 def command_create(user, parameters):
1492 """Create an element if it does not exist."""
1493 if not parameters: message = "You must at least specify an element to create."
1495 arguments = parameters.split()
1496 if len(arguments) == 1: arguments.append("")
1497 if len(arguments) == 2:
1498 element, filename = arguments
1499 if element in universe.contents: message = "The \"" + element + "\" element already exists."
1501 message = "You create \"" + element + "\" within the universe."
1502 logline = user.account.get("name") + " created an element: " + element
1504 logline += " in file " + filename
1505 if filename not in universe.files:
1506 message += " Warning: \"" + filename + "\" is not yet included in any other file and will not be read on startup unless this is remedied."
1507 Element(element, universe, filename)
1509 elif len(arguments) > 2: message = "You can only specify an element and a filename."
1512 def command_destroy(user, parameters):
1513 """Destroy an element if it exists."""
1514 if not parameters: message = "You must specify an element to destroy."
1516 if parameters not in universe.contents: message = "The \"" + parameters + "\" element does not exist."
1518 universe.contents[parameters].destroy()
1519 message = "You destroy \"" + parameters + "\" within the universe."
1520 log(user.account.get("name") + " destroyed an element: " + parameters)
1523 def command_set(user, parameters):
1524 """Set a facet of an element."""
1525 if not parameters: message = "You must specify an element, a facet and a value."
1527 arguments = parameters.split(" ", 2)
1528 if len(arguments) == 1: message = "What facet of element \"" + arguments[0] + "\" would you like to set?"
1529 elif len(arguments) == 2: message = "What value would you like to set for the \"" + arguments[1] + "\" facet of the \"" + arguments[0] + "\" element?"
1531 element, facet, value = arguments
1532 if element not in universe.contents: message = "The \"" + element + "\" element does not exist."
1534 universe.contents[element].set(facet, value)
1535 message = "You have successfully (re)set the \"" + facet + "\" facet of element \"" + element + "\". Try \"show element " + element + "\" for verification."
1538 def command_delete(user, parameters):
1539 """Delete a facet from an element."""
1540 if not parameters: message = "You must specify an element and a facet."
1542 arguments = parameters.split(" ")
1543 if len(arguments) == 1: message = "What facet of element \"" + arguments[0] + "\" would you like to delete?"
1544 elif len(arguments) != 2: message = "You may only specify an element and a facet."
1546 element, facet = arguments
1547 if element not in universe.contents: message = "The \"" + element + "\" element does not exist."
1548 elif facet not in universe.contents[element].facets(): message = "The \"" + element + "\" element has no \"" + facet + "\" facet."
1550 universe.contents[element].delete(facet)
1551 message = "You have successfully deleted the \"" + facet + "\" facet of element \"" + element + "\". Try \"show element " + element + "\" for verification."
1554 def command_error(user, input_data):
1555 """Generic error for an unrecognized command word."""
1557 # 90% of the time use a generic error
1559 message = "I'm not sure what \"" + input_data + "\" means..."
1561 # 10% of the time use the classic diku error
1563 message = "Arglebargle, glop-glyf!?!"
1565 # send the error message
1568 # if there is no universe, create an empty one
1569 if not "universe" in locals(): universe = Universe()