1 """Core objects for the mudpy engine."""
3 # Copyright (c) 2005 mudpy, The Fungi <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 self.key 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 self.echo_to_location("You suddenly realize that " + self.get("name") + " is here.")
151 def move_direction(self, direction):
152 """Relocate the element in a specified direction."""
153 self.echo_to_location(self.get("name") + " exits " + universe.categories["internal"]["directions"].getdict(direction)["exit"] + ".")
154 self.send("You exit " + universe.categories["internal"]["directions"].getdict(direction)["exit"] + ".")
155 self.go_to(universe.contents[self.get("location")].link_neighbor(direction))
156 self.echo_to_location(self.get("name") + " arrives from " + universe.categories["internal"]["directions"].getdict(direction)["enter"] + ".")
157 def look_at(self, key):
158 """Show an element to another element."""
160 element = universe.contents[key]
162 name = element.get("name")
163 if name: message += "$(cyn)" + name + "$(nrm)$(eol)"
164 description = element.get("description")
165 if description: message += description + "$(eol)"
166 portal_list = element.portals().keys()
169 message += "$(cyn)[ Exits: " + ", ".join(portal_list) + " ]$(nrm)$(eol)"
170 for element in universe.contents[self.get("location")].contents.values():
171 if element.getboolean("is_actor") and element is not self:
172 message += "$(yel)" + element.get("name") + " is here.$(nrm)$(eol)"
175 """Map the portal directions for a room to neighbors."""
177 if match("""^location:-?\d+,-?\d+,-?\d+$""", self.key):
178 coordinates = [(int(x)) for x in self.key.split(":")[1].split(",")]
179 directions = universe.categories["internal"]["directions"]
180 offsets = dict([(x, directions.getdict(x)["vector"]) for x in directions.facets()])
181 for portal in self.getlist("gridlinks"):
182 adjacent = map(lambda c,o: c+o, coordinates, offsets[portal])
183 neighbor = "location:" + ",".join([(str(x)) for x in adjacent])
184 if neighbor in universe.contents: portals[portal] = neighbor
185 for facet in self.facets():
186 if facet.startswith("link_"):
187 neighbor = self.get(facet)
188 if neighbor in universe.contents:
189 portal = facet.split("_")[1]
190 portals[portal] = neighbor
192 def link_neighbor(self, direction):
193 """Return the element linked in a given direction."""
194 portals = self.portals()
195 if direction in portals: return portals[direction]
196 def echo_to_location(self, message):
197 """Show a message to other elements in the current location."""
198 for element in universe.contents[self.get("location")].contents.values():
199 if element is not self: element.send(message)
202 """A file containing universe elements."""
203 def __init__(self, filename, universe):
204 self.data = RawConfigParser()
205 if access(filename, R_OK): self.data.read(filename)
206 self.filename = filename
207 universe.files[filename] = self
208 if self.data.has_option("__control__", "include_files"):
209 includes = makelist(self.data.get("__control__", "include_files"))
211 if self.data.has_option("__control__", "default_files"):
212 origins = makedict(self.data.get("__control__", "default_files"))
213 for key in origins.keys():
214 if not key in includes: includes.append(key)
215 universe.default_origins[key] = origins[key]
216 if not key in universe.categories:
217 universe.categories[key] = {}
218 if self.data.has_option("__control__", "private_files"):
219 for item in makelist(self.data.get("__control__", "private_files")):
220 if not item in includes: includes.append(item)
221 if not item in universe.private_files:
223 item = path_join(dirname(filename), item)
224 universe.private_files.append(item)
225 for section in self.data.sections():
226 if section != "__control__":
227 Element(section, universe, filename)
228 for include_file in includes:
229 if not isabs(include_file):
230 include_file = path_join(dirname(filename), include_file)
231 DataFile(include_file, universe)
233 """Write the data, if necessary."""
235 # when there is content or the file exists, but is not read-only
236 if ( self.data.sections() or exists(self.filename) ) and not ( self.data.has_option("__control__", "read_only") and self.data.getboolean("__control__", "read_only") ):
238 # make parent directories if necessary
239 if not exists(dirname(self.filename)):
240 makedirs(dirname(self.filename))
243 file_descriptor = file(self.filename, "w")
245 # if it's marked private, chmod it appropriately
246 if self.filename in universe.private_files and oct(S_IMODE(stat(self.filename)[ST_MODE])) != 0600:
247 chmod(self.filename, 0600)
249 # write it back sorted, instead of using ConfigParser
250 sections = self.data.sections()
252 for section in sections:
253 file_descriptor.write("[" + section + "]\n")
254 options = self.data.options(section)
256 for option in options:
257 file_descriptor.write(option + " = " + self.data.get(section, option) + "\n")
258 file_descriptor.write("\n")
260 # flush and close the file
261 file_descriptor.flush()
262 file_descriptor.close()
266 def __init__(self, filename=""):
267 """Initialize the universe."""
270 self.default_origins = {}
272 self.private_files = []
274 self.terminate_world = False
275 self.reload_modules = False
277 possible_filenames = [
283 "/usr/local/mudpy/mudpy.conf",
284 "/usr/local/mudpy/etc/mudpy.conf",
285 "/etc/mudpy/mudpy.conf",
288 for filename in possible_filenames:
289 if access(filename, R_OK): break
290 if not isabs(filename):
291 filename = abspath(filename)
292 DataFile(filename, self)
294 """Save the universe to persistent storage."""
295 for key in self.files: self.files[key].save()
297 def initialize_server_socket(self):
298 """Create and open the listening socket."""
300 # create a new ipv4 stream-type socket object
301 self.listening_socket = socket(AF_INET, SOCK_STREAM)
303 # set the socket options to allow existing open ones to be
304 # reused (fixes a bug where the server can't bind for a minute
305 # when restarting on linux systems)
306 self.listening_socket.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
308 # bind the socket to to our desired server ipa and port
309 self.listening_socket.bind((self.categories["internal"]["network"].get("host"), self.categories["internal"]["network"].getint("port")))
311 # disable blocking so we can proceed whether or not we can
313 self.listening_socket.setblocking(0)
315 # start listening on the socket
316 self.listening_socket.listen(1)
318 # note that we're now ready for user connections
319 log("Waiting for connection(s)...")
322 """This is a connected user."""
325 """Default values for the in-memory user variables."""
327 self.last_address = ""
328 self.connection = None
329 self.authenticated = False
330 self.password_tries = 0
331 self.state = "initial"
332 self.menu_seen = False
334 self.input_queue = []
335 self.output_queue = []
336 self.partial_input = ""
338 self.received_newline = True
339 self.terminator = IAC+GA
340 self.negotiation_pause = 0
345 """Log, close the connection and remove."""
346 if self.account: name = self.account.get("name")
348 if name: message = "User " + name
349 else: message = "An unnamed user"
350 message += " logged out."
352 self.deactivate_avatar()
353 self.connection.close()
357 """Save, load a new user and relocate the connection."""
359 # get out of the list
362 # create a new user object
365 # 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, just_prompt=False):
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 if not just_prompt: output = "$(eol)$(eol)" + output
474 output += eol + chr(27) + "[0m"
476 # tack on a prompt if active
477 if self.state == "active":
478 if not just_prompt: output += "$(eol)"
479 if add_prompt: output += "> "
481 # find and replace macros in the output
482 output = replace_macros(self, output)
484 # wrap the text at 80 characters
485 output = wrap_ansi_text(output, 80)
487 # tack the terminator back on
488 if terminate: output += self.terminator
490 # drop the output into the user's output queue
491 self.output_queue.append(output)
493 # if this is urgent, flush all pending output
494 if flush: self.flush()
497 """All the things to do to the user per increment."""
499 # if the world is terminating, disconnect
500 if universe.terminate_world:
501 self.state = "disconnecting"
502 self.menu_seen = False
504 # if output is paused, decrement the counter
505 if self.state == "initial":
506 if self.negotiation_pause: self.negotiation_pause -= 1
507 else: self.state = "entering_account_name"
509 # show the user a menu as needed
510 elif not self.state == "active": self.show_menu()
512 # flush any pending output in teh queue
515 # disconnect users with the appropriate state
516 if self.state == "disconnecting": self.quit()
518 # check for input and add it to the queue
521 # there is input waiting in the queue
522 if self.input_queue: handle_user_input(self)
525 """Try to send the last item in the queue and remove it."""
526 if self.output_queue:
527 if self.received_newline:
528 self.received_newline = False
529 if self.output_queue[0].startswith("\r\n"):
530 self.output_queue[0] = self.output_queue[0][2:]
532 self.connection.send(self.output_queue[0])
533 del self.output_queue[0]
538 def enqueue_input(self):
539 """Process and enqueue any new input."""
541 # check for some input
543 input_data = self.connection.recv(1024)
550 # tack this on to any previous partial
551 self.partial_input += input_data
553 # reply to and remove any IAC negotiation codes
554 self.negotiate_telnet_options()
556 # separate multiple input lines
557 new_input_lines = self.partial_input.split("\n")
559 # if input doesn't end in a newline, replace the
560 # held partial input with the last line of it
561 if not self.partial_input.endswith("\n"):
562 self.partial_input = new_input_lines.pop()
564 # otherwise, chop off the extra null input and reset
565 # the held partial input
567 new_input_lines.pop()
568 self.partial_input = ""
570 # iterate over the remaining lines
571 for line in new_input_lines:
573 # remove a trailing carriage return
574 if line.endswith("\r"): line = line.rstrip("\r")
576 # log non-printable characters remaining
577 removed = filter(lambda x: (x < " " or x > "~"), line)
579 logline = "Non-printable characters from "
580 if self.account and self.account.get("name"): logline += self.account.get("name") + ": "
581 else: logline += "unknown user: "
582 logline += repr(removed)
585 # filter out non-printables
586 line = filter(lambda x: " " <= x <= "~", line)
588 # strip off extra whitespace
591 # put on the end of the queue
592 self.input_queue.append(line)
594 def negotiate_telnet_options(self):
595 """Reply to/remove partial_input telnet negotiation options."""
597 # start at the begining of the input
600 # make a local copy to play with
601 text = self.partial_input
603 # as long as we haven't checked it all
604 while position < len(text):
606 # jump to the first IAC you find
607 position = text.find(IAC, position)
609 # if there wasn't an IAC in the input, skip to the end
610 if position < 0: position = len(text)
612 # replace a double (literal) IAC if there's an LF later
613 elif len(text) > position+1 and text[position+1] == IAC:
614 if text.find("\n", position) > 0: text = text.replace(IAC+IAC, IAC)
618 # this must be an option negotiation
619 elif len(text) > position+2 and text[position+1] in (DO, DONT, WILL, WONT):
621 negotiation = text[position+1:position+3]
623 # if we turned echo off, ignore the confirmation
624 if not self.echoing and negotiation == DO+ECHO: pass
627 elif negotiation == WILL+LINEMODE: self.send(IAC+DO+LINEMODE, raw=True)
629 # if the client likes EOR instead of GA, make a note of it
630 elif negotiation == DO+EOR: self.terminator = IAC+EOR
631 elif negotiation == DONT+EOR and self.terminator == IAC+EOR:
632 self.terminator = IAC+GA
634 # if the client doesn't want GA, oblige
635 elif negotiation == DO+SGA and self.terminator == IAC+GA:
637 self.send(IAC+WILL+SGA, raw=True)
639 # we don't want to allow anything else
640 elif text[position+1] == DO: self.send(IAC+WONT+text[position+2], raw=True)
641 elif text[position+1] == WILL: self.send(IAC+DONT+text[position+2], raw=True)
643 # strip the negotiation from the input
644 text = text.replace(text[position:position+3], "")
646 # get rid of IAC SB .* IAC SE
647 elif len(text) > position+4 and text[position:position+2] == IAC+SB:
648 end_subnegotiation = text.find(IAC+SE, position)
649 if end_subnegotiation > 0: text = text[:position] + text[end_subnegotiation+2:]
652 # otherwise, strip out a two-byte IAC command
653 elif len(text) > position+2: text = text.replace(text[position:position+2], "")
655 # and this means we got the begining of an IAC
658 # replace the input with our cleaned-up text
659 self.partial_input = text
661 def can_run(self, command):
662 """Check if the user can run this command object."""
664 # has to be in the commands category
665 if command not in universe.categories["command"].values(): result = False
667 # administrators can run any command
668 elif self.account.getboolean("administrator"): result = True
670 # everyone can run non-administrative commands
671 elif not command.getboolean("administrative"): result = True
673 # otherwise the command cannot be run by this user
676 # pass back the result
679 def new_avatar(self):
680 """Instantiate a new, unconfigured avatar for this user."""
682 while "avatar:" + self.account.get("name") + ":" + str(counter) in universe.categories["actor"].keys(): counter += 1
683 self.avatar = Element("actor:avatar:" + self.account.get("name") + ":" + str(counter), universe)
684 self.avatar.append("inherit", "template:actor")
685 self.account.append("avatars", self.avatar.key)
687 def delete_avatar(self, avatar):
688 """Remove an avatar from the world and from the user's list."""
689 if self.avatar is universe.contents[avatar]: self.avatar = None
690 universe.contents[avatar].destroy()
691 avatars = self.account.getlist("avatars")
692 avatars.remove(avatar)
693 self.account.set("avatars", avatars)
695 def activate_avatar_by_index(self, index):
696 """Enter the world with a particular indexed avatar."""
697 self.avatar = universe.contents[self.account.getlist("avatars")[index]]
698 self.avatar.owner = self
699 self.state = "active"
700 self.avatar.go_home()
702 def deactivate_avatar(self):
703 """Have the active avatar leave the world."""
705 current = self.avatar.get("location")
706 self.avatar.set("default_location", current)
707 self.avatar.echo_to_location("You suddenly wonder where " + self.avatar.get("name") + " went.")
708 del universe.contents[current].contents[self.avatar.key]
709 self.avatar.remove_facet("location")
710 self.avatar.owner = None
714 """Destroy the user and associated avatars."""
715 for avatar in self.account.getlist("avatars"): self.delete_avatar(avatar)
716 self.account.destroy()
718 def list_avatar_names(self):
719 """List names of assigned avatars."""
720 return [ universe.contents[avatar].get("name") for avatar in self.account.getlist("avatars") ]
723 """Turn string into list type."""
724 if value[0] + value[-1] == "[]": return eval(value)
725 else: return [ value ]
728 """Turn string into dict type."""
729 if value[0] + value[-1] == "{}": return eval(value)
730 elif value.find(":") > 0: return eval("{" + value + "}")
731 else: return { value: None }
733 def broadcast(message, add_prompt=True):
734 """Send a message to all connected users."""
735 for each_user in universe.userlist: each_user.send("$(eol)" + message, add_prompt=add_prompt)
740 # the time in posix log timestamp format
741 timestamp = asctime()[4:19]
743 file_name = universe.categories["internal"]["logging"].get("file")
745 file_descriptor = file(file_name, "a")
746 file_descriptor.write(timestamp + " " + message + "\n")
747 file_descriptor.flush()
748 file_descriptor.close()
750 # send the timestamp and message to standard output
751 if universe.categories["internal"]["logging"].getboolean("stdout"):
752 print(timestamp + " " + message)
754 # send the message to the system log
755 syslog_name = universe.categories["internal"]["logging"].get("syslog")
757 openlog(syslog_name, LOG_PID, LOG_INFO | LOG_DAEMON)
761 def wrap_ansi_text(text, width):
762 """Wrap text with arbitrary width while ignoring ANSI colors."""
764 # the current position in the entire text string, including all
765 # characters, printable or otherwise
766 absolute_position = 0
768 # the current text position relative to the begining of the line,
769 # ignoring color escape sequences
770 relative_position = 0
772 # whether the current character is part of a color escape sequence
775 # iterate over each character from the begining of the text
776 for each_character in text:
778 # the current character is the escape character
779 if each_character == chr(27):
782 # the current character is within an escape sequence
785 # the current character is m, which terminates the
786 # current escape sequence
787 if each_character == "m":
790 # the current character is a newline, so reset the relative
791 # position (start a new line)
792 elif each_character == "\n":
793 relative_position = 0
795 # the current character meets the requested maximum line width,
796 # so we need to backtrack and find a space at which to wrap
797 elif relative_position == width:
799 # distance of the current character examined from the
803 # count backwards until we find a space
804 while text[absolute_position - wrap_offset] != " ":
807 # insert an eol in place of the space
808 text = text[:absolute_position - wrap_offset] + "\r\n" + text[absolute_position - wrap_offset + 1:]
810 # increase the absolute position because an eol is two
811 # characters but the space it replaced was only one
812 absolute_position += 1
814 # now we're at the begining of a new line, plus the
815 # number of characters wrapped from the previous line
816 relative_position = wrap_offset
818 # as long as the character is not a carriage return and the
819 # other above conditions haven't been met, count it as a
820 # printable character
821 elif each_character != "\r":
822 relative_position += 1
824 # increase the absolute position for every character
825 absolute_position += 1
827 # return the newly-wrapped text
830 def weighted_choice(data):
831 """Takes a dict weighted by value and returns a random key."""
833 # this will hold our expanded list of keys from the data
836 # create thee expanded list of keys
837 for key in data.keys():
838 for count in range(data[key]):
841 # return one at random
842 return choice(expanded)
845 """Returns a random character name."""
847 # the vowels and consonants needed to create romaji syllables
848 vowels = [ "a", "i", "u", "e", "o" ]
849 consonants = ["'", "k", "z", "s", "sh", "z", "j", "t", "ch", "ts", "d", "n", "h", "f", "m", "y", "r", "w" ]
851 # this dict will hold our weighted list of syllables
854 # generate the list with an even weighting
855 for consonant in consonants:
857 syllables[consonant + vowel] = 1
859 # we'll build the name into this string
862 # create a name of random length from the syllables
863 for syllable in range(randrange(2, 6)):
864 name += weighted_choice(syllables)
866 # strip any leading quotemark, capitalize and return the name
867 return name.strip("'").capitalize()
869 def replace_macros(user, text, is_input=False):
870 """Replaces macros in text output."""
875 # third person pronouns
877 "female": { "obj": "her", "pos": "hers", "sub": "she" },
878 "male": { "obj": "him", "pos": "his", "sub": "he" },
879 "neuter": { "obj": "it", "pos": "its", "sub": "it" }
882 # a dict of replacement macros
885 "$(bld)": chr(27) + "[1m",
886 "$(nrm)": chr(27) + "[0m",
887 "$(blk)": chr(27) + "[30m",
888 "$(blu)": chr(27) + "[34m",
889 "$(cyn)": chr(27) + "[36m",
890 "$(grn)": chr(27) + "[32m",
891 "$(mgt)": chr(27) + "[35m",
892 "$(red)": chr(27) + "[31m",
893 "$(yel)": chr(27) + "[33m",
896 # add dynamic macros where possible
898 account_name = user.account.get("name")
900 macros["$(account)"] = account_name
902 avatar_gender = user.avatar.get("gender")
904 macros["$(tpop)"] = pronouns[avatar_gender]["obj"]
905 macros["$(tppp)"] = pronouns[avatar_gender]["pos"]
906 macros["$(tpsp)"] = pronouns[avatar_gender]["sub"]
908 # find and replace per the macros dict
909 macro_start = text.find("$(")
910 if macro_start == -1: break
911 macro_end = text.find(")", macro_start) + 1
912 macro = text[macro_start:macro_end]
913 if macro in macros.keys():
914 text = text.replace(macro, macros[macro])
916 # if we get here, log and replace it with null
918 text = text.replace(macro, "")
920 log("Unexpected replacement macro " + macro + " encountered.")
922 # replace the look-like-a-macro sequence
923 text = text.replace("$_(", "$(")
927 def escape_macros(text):
928 """Escapes replacement macros in text."""
929 return text.replace("$(", "$_(")
931 def check_time(frequency):
932 """Check for a factor of the current increment count."""
933 if type(frequency) is str:
934 frequency = universe.categories["internal"]["time"].getint(frequency)
935 if not "counters" in universe.categories["internal"]:
936 Element("internal:counters", universe)
937 return not universe.categories["internal"]["counters"].getint("elapsed") % frequency
940 """The things which should happen on each pulse, aside from reloads."""
942 # open the listening socket if it hasn't been already
943 if not hasattr(universe, "listening_socket"):
944 universe.initialize_server_socket()
946 # assign a user if a new connection is waiting
947 user = check_for_connection(universe.listening_socket)
948 if user: universe.userlist.append(user)
950 # iterate over the connected users
951 for user in universe.userlist: user.pulse()
953 # update the log every now and then
954 if check_time("frequency_log"):
955 log(str(len(universe.userlist)) + " connection(s)")
957 # periodically save everything
958 if check_time("frequency_save"):
961 # pause for a configurable amount of time (decimal seconds)
962 sleep(universe.categories["internal"]["time"].getfloat("increment"))
964 # increment the elapsed increment counter
965 universe.categories["internal"]["counters"].set("elapsed", universe.categories["internal"]["counters"].getint("elapsed") + 1)
968 """Reload data into new persistent objects."""
969 for user in universe.userlist[:]: user.reload()
971 def check_for_connection(listening_socket):
972 """Check for a waiting connection and return a new user object."""
974 # try to accept a new connection
976 connection, address = listening_socket.accept()
980 # note that we got one
981 log("Connection from " + address[0])
983 # disable blocking so we can proceed whether or not we can send/receive
984 connection.setblocking(0)
986 # create a new user object
989 # associate this connection with it
990 user.connection = connection
992 # set the user's ipa from the connection's ipa
993 user.address = address[0]
995 # let the client know we WILL EOR
996 user.send(IAC+WILL+EOR, raw=True)
997 user.negotiation_pause = 2
999 # return the new user object
1002 def get_menu(state, error=None, echoing=True, terminator="", choices=None):
1003 """Show the correct menu text to a user."""
1005 # make sure we don't reuse a mutable sequence by default
1006 if choices is None: choices = {}
1008 # begin with a telnet echo command sequence if needed
1009 message = get_echo_sequence(state, echoing)
1011 # get the description or error text
1012 message += get_menu_description(state, error)
1014 # get menu choices for the current state
1015 message += get_formatted_menu_choices(state, choices)
1017 # try to get a prompt, if it was defined
1018 message += get_menu_prompt(state)
1020 # throw in the default choice, if it exists
1021 message += get_formatted_default_menu_choice(state)
1023 # display a message indicating if echo is off
1024 message += get_echo_message(state)
1026 # tack on EOR or GA to indicate the prompt will not be followed by CRLF
1027 message += terminator
1029 # return the assembly of various strings defined above
1032 def menu_echo_on(state):
1033 """True if echo is on, false if it is off."""
1034 return universe.categories["menu"][state].getboolean("echo", True)
1036 def get_echo_sequence(state, echoing):
1037 """Build the appropriate IAC WILL/WONT ECHO sequence as needed."""
1039 # if the user has echo on and the menu specifies it should be turned
1040 # off, send: iac + will + echo + null
1041 if echoing and not menu_echo_on(state): return IAC+WILL+ECHO
1043 # if echo is not set to off in the menu and the user curently has echo
1044 # off, send: iac + wont + echo + null
1045 elif not echoing and menu_echo_on(state): return IAC+WONT+ECHO
1047 # default is not to send an echo control sequence at all
1050 def get_echo_message(state):
1051 """Return a message indicating that echo is off."""
1052 if menu_echo_on(state): return ""
1053 else: return "(won't echo) "
1055 def get_default_menu_choice(state):
1056 """Return the default choice for a menu."""
1057 return universe.categories["menu"][state].get("default")
1059 def get_formatted_default_menu_choice(state):
1060 """Default menu choice foratted for inclusion in a prompt string."""
1061 default_choice = get_default_menu_choice(state)
1062 if default_choice: return "[$(red)" + default_choice + "$(nrm)] "
1065 def get_menu_description(state, error):
1066 """Get the description or error text."""
1068 # an error condition was raised by the handler
1071 # try to get an error message matching the condition
1073 description = universe.categories["menu"][state].get("error_" + error)
1074 if not description: description = "That is not a valid choice..."
1075 description = "$(red)" + description + "$(nrm)"
1077 # there was no error condition
1080 # try to get a menu description for the current state
1081 description = universe.categories["menu"][state].get("description")
1083 # return the description or error message
1084 if description: description += "$(eol)$(eol)"
1087 def get_menu_prompt(state):
1088 """Try to get a prompt, if it was defined."""
1089 prompt = universe.categories["menu"][state].get("prompt")
1090 if prompt: prompt += " "
1093 def get_menu_choices(user):
1094 """Return a dict of choice:meaning."""
1095 menu = universe.categories["menu"][user.state]
1096 create_choices = menu.get("create")
1097 if create_choices: choices = eval(create_choices)
1102 for facet in menu.facets():
1103 if facet.startswith("demand_") and not eval(universe.categories["menu"][user.state].get(facet)):
1104 ignores.append(facet.split("_", 2)[1])
1105 elif facet.startswith("create_"):
1106 creates[facet] = facet.split("_", 2)[1]
1107 elif facet.startswith("choice_"):
1108 options[facet] = facet.split("_", 2)[1]
1109 for facet in creates.keys():
1110 if not creates[facet] in ignores:
1111 choices[creates[facet]] = eval(menu.get(facet))
1112 for facet in options.keys():
1113 if not options[facet] in ignores:
1114 choices[options[facet]] = menu.get(facet)
1117 def get_formatted_menu_choices(state, choices):
1118 """Returns a formatted string of menu choices."""
1120 choice_keys = choices.keys()
1122 for choice in choice_keys:
1123 choice_output += " [$(red)" + choice + "$(nrm)] " + choices[choice] + "$(eol)"
1124 if choice_output: choice_output += "$(eol)"
1125 return choice_output
1127 def get_menu_branches(state):
1128 """Return a dict of choice:branch."""
1130 for facet in universe.categories["menu"][state].facets():
1131 if facet.startswith("branch_"):
1132 branches[facet.split("_", 2)[1]] = universe.categories["menu"][state].get(facet)
1135 def get_default_branch(state):
1136 """Return the default branch."""
1137 return universe.categories["menu"][state].get("branch")
1139 def get_choice_branch(user, choice):
1140 """Returns the new state matching the given choice."""
1141 branches = get_menu_branches(user.state)
1142 if choice in branches.keys(): return branches[choice]
1143 elif choice in user.menu_choices.keys(): return get_default_branch(user.state)
1146 def get_menu_actions(state):
1147 """Return a dict of choice:branch."""
1149 for facet in universe.categories["menu"][state].facets():
1150 if facet.startswith("action_"):
1151 actions[facet.split("_", 2)[1]] = universe.categories["menu"][state].get(facet)
1154 def get_default_action(state):
1155 """Return the default action."""
1156 return universe.categories["menu"][state].get("action")
1158 def get_choice_action(user, choice):
1159 """Run any indicated script for the given choice."""
1160 actions = get_menu_actions(user.state)
1161 if choice in actions.keys(): return actions[choice]
1162 elif choice in user.menu_choices.keys(): return get_default_action(user.state)
1165 def handle_user_input(user):
1166 """The main handler, branches to a state-specific handler."""
1168 # check to make sure the state is expected, then call that handler
1169 if "handler_" + user.state in globals():
1170 exec("handler_" + user.state + "(user)")
1172 generic_menu_handler(user)
1174 # since we got input, flag that the menu/prompt needs to be redisplayed
1175 user.menu_seen = False
1177 # if the user's client echo is off, send a blank line for aesthetics
1178 if user.echoing: user.received_newline = True
1180 def generic_menu_handler(user):
1181 """A generic menu choice handler."""
1183 # get a lower-case representation of the next line of input
1184 if user.input_queue:
1185 choice = user.input_queue.pop(0)
1186 if choice: choice = choice.lower()
1188 if not choice: choice = get_default_menu_choice(user.state)
1189 if choice in user.menu_choices:
1190 exec(get_choice_action(user, choice))
1191 new_state = get_choice_branch(user, choice)
1192 if new_state: user.state = new_state
1193 else: user.error = "default"
1195 def handler_entering_account_name(user):
1196 """Handle the login account name."""
1198 # get the next waiting line of input
1199 input_data = user.input_queue.pop(0)
1201 # did the user enter anything?
1204 # keep only the first word and convert to lower-case
1205 name = input_data.lower()
1207 # fail if there are non-alphanumeric characters
1208 if name != filter(lambda x: x>="0" and x<="9" or x>="a" and x<="z", name):
1209 user.error = "bad_name"
1211 # if that account exists, time to request a password
1212 elif name in universe.categories["account"]:
1213 user.account = universe.categories["account"][name]
1214 user.state = "checking_password"
1216 # otherwise, this could be a brand new user
1218 user.account = Element("account:" + name, universe)
1219 user.account.set("name", name)
1220 log("New user: " + name)
1221 user.state = "checking_new_account_name"
1223 # if the user entered nothing for a name, then buhbye
1225 user.state = "disconnecting"
1227 def handler_checking_password(user):
1228 """Handle the login account password."""
1230 # get the next waiting line of input
1231 input_data = user.input_queue.pop(0)
1233 # does the hashed input equal the stored hash?
1234 if new_md5(user.account.get("name") + input_data).hexdigest() == user.account.get("passhash"):
1236 # if so, set the username and load from cold storage
1237 if not user.replace_old_connections():
1239 user.state = "main_utility"
1241 # if at first your hashes don't match, try, try again
1242 elif user.password_tries < universe.categories["internal"]["limits"].getint("password_tries") - 1:
1243 user.password_tries += 1
1244 user.error = "incorrect"
1246 # we've exceeded the maximum number of password failures, so disconnect
1248 user.send("$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)")
1249 user.state = "disconnecting"
1251 def handler_entering_new_password(user):
1252 """Handle a new password entry."""
1254 # get the next waiting line of input
1255 input_data = user.input_queue.pop(0)
1257 # make sure the password is strong--at least one upper, one lower and
1258 # one digit, seven or more characters in length
1259 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)):
1261 # hash and store it, then move on to verification
1262 user.account.set("passhash", new_md5(user.account.get("name") + input_data).hexdigest())
1263 user.state = "verifying_new_password"
1265 # the password was weak, try again if you haven't tried too many times
1266 elif user.password_tries < universe.categories["internal"]["limits"].getint("password_tries") - 1:
1267 user.password_tries += 1
1270 # too many tries, so adios
1272 user.send("$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)")
1273 user.account.destroy()
1274 user.state = "disconnecting"
1276 def handler_verifying_new_password(user):
1277 """Handle the re-entered new password for verification."""
1279 # get the next waiting line of input
1280 input_data = user.input_queue.pop(0)
1282 # hash the input and match it to storage
1283 if new_md5(user.account.get("name") + input_data).hexdigest() == user.account.get("passhash"):
1286 # the hashes matched, so go active
1287 if not user.replace_old_connections(): user.state = "main_utility"
1289 # go back to entering the new password as long as you haven't tried
1291 elif user.password_tries < universe.categories["internal"]["limits"].getint("password_tries") - 1:
1292 user.password_tries += 1
1293 user.error = "differs"
1294 user.state = "entering_new_password"
1296 # otherwise, sayonara
1298 user.send("$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)")
1299 user.account.destroy()
1300 user.state = "disconnecting"
1302 def handler_active(user):
1303 """Handle input for active users."""
1305 # get the next waiting line of input
1306 input_data = user.input_queue.pop(0)
1311 # split out the command (first word) and parameters (everything else)
1312 if input_data.find(" ") > 0:
1313 command_name, parameters = input_data.split(" ", 1)
1315 command_name = input_data
1318 # lowercase the command
1319 command_name = command_name.lower()
1321 # the command matches a command word for which we have data
1322 if command_name in universe.categories["command"]:
1323 command = universe.categories["command"][command_name]
1324 else: command = None
1326 # if it's allowed, do it
1327 if user.can_run(command): exec(command.get("action"))
1329 # otherwise, give an error
1330 elif command_name: command_error(user, input_data)
1332 # if no input, just idle back with a prompt
1333 else: user.send("", just_prompt=True)
1335 def command_halt(user, parameters):
1336 """Halt the world."""
1338 # see if there's a message or use a generic one
1339 if parameters: message = "Halting: " + parameters
1340 else: message = "User " + user.account.get("name") + " halted the world."
1343 broadcast(message, add_prompt=False)
1346 # set a flag to terminate the world
1347 universe.terminate_world = True
1349 def command_reload(user):
1350 """Reload all code modules, configs and data."""
1352 # let the user know and log
1353 user.send("Reloading all code modules, configs and data.")
1354 log("User " + user.account.get("name") + " reloaded the world.")
1356 # set a flag to reload
1357 universe.reload_modules = True
1359 def command_quit(user):
1360 """Leave the world and go back to the main menu."""
1361 user.deactivate_avatar()
1362 user.state = "main_utility"
1364 def command_help(user, parameters):
1365 """List available commands and provide help for commands."""
1367 # did the user ask for help on a specific command word?
1370 # is the command word one for which we have data?
1371 if parameters in universe.categories["command"]:
1372 command = universe.categories["command"][parameters]
1373 else: command = None
1375 # only for allowed commands
1376 if user.can_run(command):
1378 # add a description if provided
1379 description = command.get("description")
1381 description = "(no short description provided)"
1382 if command.getboolean("administrative"): output = "$(red)"
1383 else: output = "$(grn)"
1384 output += parameters + "$(nrm) - " + description + "$(eol)$(eol)"
1386 # add the help text if provided
1387 help_text = command.get("help")
1389 help_text = "No help is provided for this command."
1392 # no data for the requested command word
1394 output = "That is not an available command."
1396 # no specific command word was indicated
1399 # give a sorted list of commands with descriptions if provided
1400 output = "These are the commands available to you:$(eol)$(eol)"
1401 sorted_commands = universe.categories["command"].keys()
1402 sorted_commands.sort()
1403 for item in sorted_commands:
1404 command = universe.categories["command"][item]
1405 if user.can_run(command):
1406 description = command.get("description")
1408 description = "(no short description provided)"
1409 if command.getboolean("administrative"): output += " $(red)"
1410 else: output += " $(grn)"
1411 output += item + "$(nrm) - " + description + "$(eol)"
1412 output += "$(eol)Enter \"help COMMAND\" for help on a command named \"COMMAND\"."
1414 # send the accumulated output to the user
1417 def command_move(user, parameters):
1418 """Move the avatar in a given direction."""
1419 if parameters in universe.contents[user.avatar.get("location")].portals():
1420 user.avatar.move_direction(parameters)
1421 else: user.send("You cannot go that way.")
1423 def command_look(user, parameters):
1425 if parameters: user.send("You look at or in anything yet.")
1426 else: user.avatar.look_at(user.avatar.get("location"))
1428 def command_say(user, parameters):
1429 """Speak to others in the same room."""
1431 # check for replacement macros
1432 if replace_macros(user, parameters, True) != parameters:
1433 user.send("You cannot speak $_(replacement macros).")
1435 # the user entered a message
1438 # get rid of quote marks on the ends of the message and
1439 # capitalize the first letter
1440 message = parameters.strip("\"'`").capitalize()
1442 # a dictionary of punctuation:action pairs
1444 for facet in universe.categories["internal"]["language"].facets():
1445 if facet.startswith("punctuation_"):
1446 action = facet.split("_")[1]
1447 for mark in universe.categories["internal"]["language"].getlist(facet):
1448 actions[mark] = action
1450 # match the punctuation used, if any, to an action
1451 default_punctuation = universe.categories["internal"]["language"].get("default_punctuation")
1452 action = actions[default_punctuation]
1453 for mark in actions.keys():
1454 if message.endswith(mark) and mark != default_punctuation:
1455 action = actions[mark]
1458 # if the action is default and there is no mark, add one
1459 if action == actions[default_punctuation] and not message.endswith(default_punctuation):
1460 message += default_punctuation
1462 # capitalize a list of words within the message
1463 capitalize_words = universe.categories["internal"]["language"].getlist("capitalize_words")
1464 for word in capitalize_words:
1465 message = message.replace(" " + word + " ", " " + word.capitalize() + " ")
1468 user.avatar.echo_to_location(user.avatar.get("name") + " " + action + "s, \"" + message + "\"")
1469 user.send("You " + action + ", \"" + message + "\"")
1471 # there was no message
1473 user.send("What do you want to say?")
1475 def command_show(user, parameters):
1476 """Show program data."""
1478 if parameters.find(" ") < 1:
1479 if parameters == "time":
1480 message = universe.categories["internal"]["counters"].get("elapsed") + " increments elapsed since the world was created."
1481 elif parameters == "categories":
1482 message = "These are the element categories:$(eol)"
1483 categories = universe.categories.keys()
1485 for category in categories: message += "$(eol) $(grn)" + category + "$(nrm)"
1486 elif parameters == "files":
1487 message = "These are the current files containing the universe:$(eol)"
1488 filenames = universe.files.keys()
1490 for filename in filenames: message += "$(eol) $(grn)" + filename + "$(nrm)"
1493 arguments = parameters.split()
1494 if arguments[0] == "category":
1495 if arguments[1] in universe.categories:
1496 message = "These are the elements in the \"" + arguments[1] + "\" category:$(eol)"
1497 elements = universe.categories[arguments[1]].keys()
1499 for element in elements:
1500 message += "$(eol) $(grn)" + universe.categories[arguments[1]][element].key + "$(nrm)"
1501 elif arguments[0] == "element":
1502 if arguments[1] in universe.contents:
1503 message = "These are the properties of the \"" + arguments[1] + "\" element:$(eol)"
1504 element = universe.contents[arguments[1]]
1505 facets = element.facets()
1507 for facet in facets:
1508 message += "$(eol) $(grn)" + facet + ": $(red)" + escape_macros(element.get(facet)) + "$(nrm)"
1509 elif arguments[0] == "result":
1510 if len(arguments) > 1:
1512 message = repr(eval(" ".join(arguments[1:])))
1514 message = "Your expression raised an exception!"
1516 if parameters: message = "I don't know what \"" + parameters + "\" is."
1517 else: message = "What do you want to show?"
1520 def command_create(user, parameters):
1521 """Create an element if it does not exist."""
1522 if not parameters: message = "You must at least specify an element to create."
1524 arguments = parameters.split()
1525 if len(arguments) == 1: arguments.append("")
1526 if len(arguments) == 2:
1527 element, filename = arguments
1528 if element in universe.contents: message = "The \"" + element + "\" element already exists."
1530 message = "You create \"" + element + "\" within the universe."
1531 logline = user.account.get("name") + " created an element: " + element
1533 logline += " in file " + filename
1534 if filename not in universe.files:
1535 message += " Warning: \"" + filename + "\" is not yet included in any other file and will not be read on startup unless this is remedied."
1536 Element(element, universe, filename)
1538 elif len(arguments) > 2: message = "You can only specify an element and a filename."
1541 def command_destroy(user, parameters):
1542 """Destroy an element if it exists."""
1543 if not parameters: message = "You must specify an element to destroy."
1545 if parameters not in universe.contents: message = "The \"" + parameters + "\" element does not exist."
1547 universe.contents[parameters].destroy()
1548 message = "You destroy \"" + parameters + "\" within the universe."
1549 log(user.account.get("name") + " destroyed an element: " + parameters)
1552 def command_set(user, parameters):
1553 """Set a facet of an element."""
1554 if not parameters: message = "You must specify an element, a facet and a value."
1556 arguments = parameters.split(" ", 2)
1557 if len(arguments) == 1: message = "What facet of element \"" + arguments[0] + "\" would you like to set?"
1558 elif len(arguments) == 2: message = "What value would you like to set for the \"" + arguments[1] + "\" facet of the \"" + arguments[0] + "\" element?"
1560 element, facet, value = arguments
1561 if element not in universe.contents: message = "The \"" + element + "\" element does not exist."
1563 universe.contents[element].set(facet, value)
1564 message = "You have successfully (re)set the \"" + facet + "\" facet of element \"" + element + "\". Try \"show element " + element + "\" for verification."
1567 def command_delete(user, parameters):
1568 """Delete a facet from an element."""
1569 if not parameters: message = "You must specify an element and a facet."
1571 arguments = parameters.split(" ")
1572 if len(arguments) == 1: message = "What facet of element \"" + arguments[0] + "\" would you like to delete?"
1573 elif len(arguments) != 2: message = "You may only specify an element and a facet."
1575 element, facet = arguments
1576 if element not in universe.contents: message = "The \"" + element + "\" element does not exist."
1577 elif facet not in universe.contents[element].facets(): message = "The \"" + element + "\" element has no \"" + facet + "\" facet."
1579 universe.contents[element].delete(facet)
1580 message = "You have successfully deleted the \"" + facet + "\" facet of element \"" + element + "\". Try \"show element " + element + "\" for verification."
1583 def command_error(user, input_data):
1584 """Generic error for an unrecognized command word."""
1586 # 90% of the time use a generic error
1588 message = "I'm not sure what \"" + input_data + "\" means..."
1590 # 10% of the time use the classic diku error
1592 message = "Arglebargle, glop-glyf!?!"
1594 # send the error message
1597 # if there is no universe, create an empty one
1598 if not "universe" in locals(): universe = Universe()