1 # -*- coding: utf-8 -*-
2 """Miscellaneous functions for the mudpy engine."""
4 # Copyright (c) 2004-2014 Jeremy Stanley <fungi@yuggoth.org>. Permission
5 # to use, copy, modify, and distribute this software is granted under
6 # terms provided in the LICENSE file distributed with this software.
25 """An element of the universe."""
27 def __init__(self, key, universe, filename=None):
28 """Set up a new element."""
30 # keep track of our key name
33 # keep track of what universe it's loading into
34 self.universe = universe
36 # clone attributes if this is replacing another element
37 if self.key in self.universe.contents:
38 old_element = self.universe.contents[self.key]
39 for attribute in vars(old_element).keys():
40 exec("self." + attribute + " = old_element." + attribute)
42 self.owner.avatar = self
44 # i guess this is a new element then
47 # not owned by a user by default (used for avatars)
50 # no contents in here by default
53 # parse out appropriate category and subkey names, add to list
54 if self.key.find(":") > 0:
55 self.category, self.subkey = self.key.split(":", 1)
57 self.category = "other"
58 self.subkey = self.key
59 if self.category not in self.universe.categories:
60 self.category = "other"
61 self.subkey = self.key
63 # get an appropriate filename for the origin
65 filename = self.universe.default_origins[self.category]
66 if not os.path.isabs(filename):
67 filename = os.path.abspath(filename)
69 # add the file if it doesn't exist yet
70 if filename not in self.universe.files:
71 mudpy.data.DataFile(filename, self.universe)
73 # record or reset a pointer to the origin file
74 self.origin = self.universe.files[filename]
76 # add a data section to the origin if necessary
77 # TODO(fungi): remove this indirection after the YAML transition
78 if self.origin._format == "yaml":
79 if self.key not in self.origin.data:
80 self.origin.data[self.key] = {}
82 if not self.origin.data.has_section(self.key):
83 self.origin.data.add_section(self.key)
85 # add or replace this element in the universe
86 self.universe.contents[self.key] = self
87 self.universe.categories[self.category][self.subkey] = self
90 """Create a new element and replace this one."""
91 Element(self.key, self.universe, self.origin.filename)
95 """Remove an element from the universe and destroy it."""
96 self.origin.data.remove_section(self.key)
97 del self.universe.categories[self.category][self.subkey]
98 del self.universe.contents[self.key]
102 """Return a list of non-inherited facets for this element."""
103 # TODO(fungi): remove this indirection after the YAML transition
104 if self.origin._format == "yaml":
106 return self.origin.data[self.key].keys()
107 except (AttributeError, KeyError):
110 if self.key in self.origin.data.sections():
111 return self.origin.data.options(self.key)
115 def has_facet(self, facet):
116 """Return whether the non-inherited facet exists."""
117 return facet in self.facets()
119 def remove_facet(self, facet):
120 """Remove a facet from the element."""
121 if self.has_facet(facet):
122 self.origin.data.remove_option(self.key, facet)
123 self.origin.modified = True
126 """Return a list of the element's inheritance lineage."""
127 if self.has_facet("inherit"):
128 ancestry = self.getlist("inherit")
129 for parent in ancestry[:]:
130 ancestors = self.universe.contents[parent].ancestry()
131 for ancestor in ancestors:
132 if ancestor not in ancestry:
133 ancestry.append(ancestor)
138 def get(self, facet, default=None):
139 """Retrieve values."""
142 # TODO(fungi): remove this indirection after the YAML transition
143 if self.origin._format == "yaml":
145 return self.origin.data[self.key][facet]
146 except (KeyError, TypeError):
148 if self.has_facet("inherit"):
149 for ancestor in self.ancestry():
150 if self.universe.contents[ancestor].has_facet(facet):
151 return self.universe.contents[ancestor].get(facet)
155 if self.origin.data.has_option(self.key, facet):
156 raw_data = self.origin.data.get(self.key, facet)
157 if raw_data.startswith("u\"") or raw_data.startswith("u'"):
158 raw_data = raw_data[1:]
159 raw_data.strip("\"'")
161 elif self.has_facet("inherit"):
162 for ancestor in self.ancestry():
163 if self.universe.contents[ancestor].has_facet(facet):
164 return self.universe.contents[ancestor].get(facet)
168 def getboolean(self, facet, default=None):
169 """Retrieve values as boolean type."""
172 # TODO(fungi): remove this indirection after the YAML transition
173 if self.origin._format == "yaml":
175 return bool(self.origin.data[self.key][facet])
178 for ancestor in self.ancestry():
180 return self.universe.contents[ancestor].getboolean(facet)
185 if self.origin.data.has_option(self.key, facet):
186 return self.origin.data.getboolean(self.key, facet)
187 elif self.has_facet("inherit"):
188 for ancestor in self.ancestry():
189 if self.universe.contents[ancestor].has_facet(facet):
190 return self.universe.contents[ancestor].getboolean(
195 def getint(self, facet, default=None):
196 """Return values as int type."""
199 if self.origin.data.has_option(self.key, facet):
200 return self.origin.data.getint(self.key, facet)
201 elif self.has_facet("inherit"):
202 for ancestor in self.ancestry():
203 if self.universe.contents[ancestor].has_facet(facet):
204 return self.universe.contents[ancestor].getint(facet)
208 def getfloat(self, facet, default=None):
209 """Return values as float type."""
212 if self.origin.data.has_option(self.key, facet):
213 return self.origin.data.getfloat(self.key, facet)
214 elif self.has_facet("inherit"):
215 for ancestor in self.ancestry():
216 if self.universe.contents[ancestor].has_facet(facet):
217 return self.universe.contents[ancestor].getfloat(facet)
221 def getlist(self, facet, default=None):
222 """Return values as list type."""
225 value = self.get(facet)
227 if type(value) is list:
230 return mudpy.data.makelist(value)
234 def getdict(self, facet, default=None):
235 """Return values as dict type."""
238 value = self.get(facet)
240 if type(value) is dict:
243 return mudpy.data.makedict(value)
247 def set(self, facet, value):
249 if not self.has_facet(facet) or not self.get(facet) == value:
250 if not type(value) is str:
252 self.origin.data.set(self.key, facet, value)
253 self.origin.modified = True
255 def append(self, facet, value):
256 """Append value to a list."""
257 if not type(value) is str:
259 newlist = self.getlist(facet)
260 newlist.append(value)
261 self.set(facet, newlist)
271 add_terminator=False,
274 """Convenience method to pass messages to an owner."""
287 def can_run(self, command):
288 """Check if the user can run this command object."""
290 # has to be in the commands category
291 if command not in self.universe.categories["command"].values():
294 # avatars of administrators can run any command
295 elif self.owner and self.owner.account.getboolean("administrator"):
298 # everyone can run non-administrative commands
299 elif not command.getboolean("administrative"):
302 # otherwise the command cannot be run by this actor
306 # pass back the result
309 def update_location(self):
310 """Make sure the location's contents contain this element."""
311 area = self.get("location")
312 if area in self.universe.contents:
313 self.universe.contents[area].contents[self.key] = self
315 def clean_contents(self):
316 """Make sure the element's contents aren't bogus."""
317 for element in self.contents.values():
318 if element.get("location") != self.key:
319 del self.contents[element.key]
321 def go_to(self, area):
322 """Relocate the element to a specific area."""
323 current = self.get("location")
324 if current and self.key in self.universe.contents[current].contents:
325 del universe.contents[current].contents[self.key]
326 if area in self.universe.contents:
327 self.set("location", area)
328 self.universe.contents[area].contents[self.key] = self
332 """Relocate the element to its default location."""
333 self.go_to(self.get("default_location"))
334 self.echo_to_location(
335 "You suddenly realize that " + self.get("name") + " is here."
338 def move_direction(self, direction):
339 """Relocate the element in a specified direction."""
340 self.echo_to_location(
343 ) + " exits " + self.universe.categories[
354 "You exit " + self.universe.categories[
366 self.universe.contents[
367 self.get("location")].link_neighbor(direction)
369 self.echo_to_location(
372 ) + " arrives from " + self.universe.categories[
383 def look_at(self, key):
384 """Show an element to another element."""
386 element = self.universe.contents[key]
388 name = element.get("name")
390 message += "$(cyn)" + name + "$(nrm)$(eol)"
391 description = element.get("description")
393 message += description + "$(eol)"
394 portal_list = list(element.portals().keys())
397 message += "$(cyn)[ Exits: " + ", ".join(
400 for element in self.universe.contents[
403 if element.getboolean("is_actor") and element is not self:
404 message += "$(yel)" + element.get(
406 ) + " is here.$(nrm)$(eol)"
407 elif element is not self:
408 message += "$(grn)" + element.get(
414 """Map the portal directions for an area to neighbors."""
416 if re.match("""^area:-?\d+,-?\d+,-?\d+$""", self.key):
417 coordinates = [(int(x))
418 for x in self.key.split(":")[1].split(",")]
419 directions = self.universe.categories["internal"]["directions"]
423 x, directions.getdict(x)["vector"]
424 ) for x in directions.facets()
427 for portal in self.getlist("gridlinks"):
428 adjacent = map(lambda c, o: c + o,
429 coordinates, offsets[portal])
430 neighbor = "area:" + ",".join(
431 [(str(x)) for x in adjacent]
433 if neighbor in self.universe.contents:
434 portals[portal] = neighbor
435 for facet in self.facets():
436 if facet.startswith("link_"):
437 neighbor = self.get(facet)
438 if neighbor in self.universe.contents:
439 portal = facet.split("_")[1]
440 portals[portal] = neighbor
443 def link_neighbor(self, direction):
444 """Return the element linked in a given direction."""
445 portals = self.portals()
446 if direction in portals:
447 return portals[direction]
449 def echo_to_location(self, message):
450 """Show a message to other elements in the current location."""
451 for element in self.universe.contents[
454 if element is not self:
455 element.send(message)
462 def __init__(self, filename="", load=False):
463 """Initialize the universe."""
466 self.default_origins = {}
468 self.private_files = []
469 self.reload_flag = False
470 self.startdir = os.getcwd()
471 self.terminate_flag = False
474 possible_filenames = [
480 "/usr/local/mudpy/mudpy.conf",
481 "/usr/local/mudpy/etc/mudpy.conf",
482 "/etc/mudpy/mudpy.conf",
485 for filename in possible_filenames:
486 if os.access(filename, os.R_OK):
488 if not os.path.isabs(filename):
489 filename = os.path.join(self.startdir, filename)
490 self.filename = filename
495 """Load universe data from persistent storage."""
497 # the files dict must exist and filename needs to be read-only
501 self.filename in self.files and self.files[
506 # clear out all read-only files
507 if hasattr(self, "files"):
508 for data_filename in list(self.files.keys()):
509 if not self.files[data_filename].is_writeable():
510 del self.files[data_filename]
512 # start loading from the initial file
513 mudpy.data.DataFile(self.filename, self)
515 # make a list of inactive avatars
516 inactive_avatars = []
517 for account in self.categories["account"].values():
518 inactive_avatars += [
519 (self.contents[x]) for x in account.getlist("avatars")
521 for user in self.userlist:
522 if user.avatar in inactive_avatars:
523 inactive_avatars.remove(user.avatar)
525 # go through all elements to clear out inactive avatar locations
526 for element in self.contents.values():
527 area = element.get("location")
528 if element in inactive_avatars and area:
529 if area in self.contents and element.key in self.contents[
532 del self.contents[area].contents[element.key]
533 element.set("default_location", area)
534 element.remove_facet("location")
536 # another pass to straighten out all the element contents
537 for element in self.contents.values():
538 element.update_location()
539 element.clean_contents()
542 """Create a new, empty Universe (the Big Bang)."""
543 new_universe = Universe()
544 for attribute in vars(self).keys():
545 exec("new_universe." + attribute + " = self." + attribute)
546 new_universe.reload_flag = False
551 """Save the universe to persistent storage."""
552 for key in self.files:
553 self.files[key].save()
555 def initialize_server_socket(self):
556 """Create and open the listening socket."""
558 # need to know the local address and port number for the listener
559 host = self.categories["internal"]["network"].get("host")
560 port = self.categories["internal"]["network"].getint("port")
562 # if no host was specified, bind to all local addresses (preferring
570 # figure out if this is ipv4 or v6
571 family = socket.getaddrinfo(host, port)[0][0]
572 if family is socket.AF_INET6 and not socket.has_ipv6:
573 log("No support for IPv6 address %s (use IPv4 instead)." % host)
575 # create a new stream-type socket object
576 self.listening_socket = socket.socket(family, socket.SOCK_STREAM)
578 # set the socket options to allow existing open ones to be
579 # reused (fixes a bug where the server can't bind for a minute
580 # when restarting on linux systems)
581 self.listening_socket.setsockopt(
582 socket.SOL_SOCKET, socket.SO_REUSEADDR, 1
585 # bind the socket to to our desired server ipa and port
586 self.listening_socket.bind((host, port))
588 # disable blocking so we can proceed whether or not we can
590 self.listening_socket.setblocking(0)
592 # start listening on the socket
593 self.listening_socket.listen(1)
595 # note that we're now ready for user connections
597 "Listening for Telnet connections on: " +
598 host + ":" + str(port)
602 """Convenience method to get the elapsed time counter."""
603 return self.categories["internal"]["counters"].getint("elapsed")
608 """This is a connected user."""
611 """Default values for the in-memory user variables."""
614 self.authenticated = False
617 self.connection = None
619 self.input_queue = []
620 self.last_address = ""
621 self.last_input = universe.get_time()
622 self.menu_choices = {}
623 self.menu_seen = False
624 self.negotiation_pause = 0
625 self.output_queue = []
626 self.partial_input = b""
627 self.password_tries = 0
628 self.state = "initial"
632 """Log, close the connection and remove."""
634 name = self.account.get("name")
638 message = "User " + name
640 message = "An unnamed user"
641 message += " logged out."
643 self.deactivate_avatar()
644 self.connection.close()
647 def check_idle(self):
648 """Warn or disconnect idle users as appropriate."""
649 idletime = universe.get_time() - self.last_input
650 linkdead_dict = universe.categories["internal"]["time"].getdict(
653 if self.state in linkdead_dict:
654 linkdead_state = self.state
656 linkdead_state = "default"
657 if idletime > linkdead_dict[linkdead_state]:
659 "$(eol)$(red)You've done nothing for far too long... goodbye!"
664 logline = "Disconnecting "
665 if self.account and self.account.get("name"):
666 logline += self.account.get("name")
668 logline += "an unknown user"
669 logline += (" after idling too long in the " + self.state
672 self.state = "disconnecting"
673 self.menu_seen = False
674 idle_dict = universe.categories["internal"]["time"].getdict("idle")
675 if self.state in idle_dict:
676 idle_state = self.state
678 idle_state = "default"
679 if idletime == idle_dict[idle_state]:
681 "$(eol)$(red)If you continue to be unproductive, "
682 + "you'll be shown the door...$(nrm)$(eol)"
686 """Save, load a new user and relocate the connection."""
688 # get out of the list
691 # create a new user object
694 # set everything equivalent
695 for attribute in vars(self).keys():
696 exec("new_user." + attribute + " = self." + attribute)
698 # the avatar needs a new owner
700 new_user.avatar.owner = new_user
703 universe.userlist.append(new_user)
705 # get rid of the old user object
708 def replace_old_connections(self):
709 """Disconnect active users with the same name."""
711 # the default return value
714 # iterate over each user in the list
715 for old_user in universe.userlist:
717 # the name is the same but it's not us
720 ) and old_user.account and old_user.account.get(
722 ) == self.account.get(
724 ) and old_user is not self:
728 "User " + self.account.get(
730 ) + " reconnected--closing old connection to "
731 + old_user.address + ".",
735 "$(eol)$(red)New connection from " + self.address
736 + ". Terminating old connection...$(nrm)$(eol)",
741 # close the old connection
742 old_user.connection.close()
744 # replace the old connection with this one
746 "$(eol)$(red)Taking over old connection from "
747 + old_user.address + ".$(nrm)"
749 old_user.connection = self.connection
750 old_user.last_address = old_user.address
751 old_user.address = self.address
753 # take this one out of the list and delete
759 # true if an old connection was replaced, false if not
762 def authenticate(self):
763 """Flag the user as authenticated and disconnect duplicates."""
764 if self.state is not "authenticated":
765 log("User " + self.account.get("name") + " logged in.", 2)
766 self.authenticated = True
767 if self.account.subkey in universe.categories[
774 self.account.set("administrator", "True")
777 """Send the user their current menu."""
778 if not self.menu_seen:
779 self.menu_choices = get_menu_choices(self)
781 get_menu(self.state, self.error, self.menu_choices),
785 self.menu_seen = True
787 self.adjust_echoing()
789 def adjust_echoing(self):
790 """Adjust echoing to match state menu requirements."""
791 if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_ECHO,
793 if menu_echo_on(self.state):
794 mudpy.telnet.disable(self, mudpy.telnet.TELOPT_ECHO,
796 elif not menu_echo_on(self.state):
797 mudpy.telnet.enable(self, mudpy.telnet.TELOPT_ECHO,
801 """Remove a user from the list of connected users."""
802 universe.userlist.remove(self)
812 add_terminator=False,
815 """Send arbitrary text to a connected user."""
817 # unless raw mode is on, clean it up all nice and pretty
820 # strip extra $(eol) off if present
821 while output.startswith("$(eol)"):
823 while output.endswith("$(eol)"):
825 extra_lines = output.find("$(eol)$(eol)$(eol)")
826 while extra_lines > -1:
827 output = output[:extra_lines] + output[extra_lines + 6:]
828 extra_lines = output.find("$(eol)$(eol)$(eol)")
830 # start with a newline, append the message, then end
831 # with the optional eol string passed to this function
832 # and the ansi escape to return to normal text
833 if not just_prompt and prepend_padding:
834 if (not self.output_queue or not
835 self.output_queue[-1].endswith(b"\r\n")):
836 output = "$(eol)" + output
837 elif not self.output_queue[-1].endswith(
839 ) and not self.output_queue[-1].endswith(
842 output = "$(eol)" + output
843 output += eol + chr(27) + "[0m"
845 # tack on a prompt if active
846 if self.state == "active":
851 mode = self.avatar.get("mode")
853 output += "(" + mode + ") "
855 # find and replace macros in the output
856 output = replace_macros(self, output)
858 # wrap the text at the client's width (min 40, 0 disables)
860 if self.columns < 40:
864 output = wrap_ansi_text(output, wrap)
866 # if supported by the client, encode it utf-8
867 if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_BINARY,
869 encoded_output = output.encode("utf-8")
871 # otherwise just send ascii
873 encoded_output = output.encode("ascii", "replace")
875 # end with a terminator if requested
876 if add_prompt or add_terminator:
877 if mudpy.telnet.is_enabled(
878 self, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US):
879 encoded_output += mudpy.telnet.telnet_proto(
880 mudpy.telnet.IAC, mudpy.telnet.EOR)
881 elif not mudpy.telnet.is_enabled(
882 self, mudpy.telnet.TELOPT_SGA, mudpy.telnet.US):
883 encoded_output += mudpy.telnet.telnet_proto(
884 mudpy.telnet.IAC, mudpy.telnet.GA)
886 # and tack it onto the queue
887 self.output_queue.append(encoded_output)
889 # if this is urgent, flush all pending output
893 # just dump raw bytes as requested
895 self.output_queue.append(output)
899 """All the things to do to the user per increment."""
901 # if the world is terminating, disconnect
902 if universe.terminate_flag:
903 self.state = "disconnecting"
904 self.menu_seen = False
906 # check for an idle connection and act appropriately
910 # if output is paused, decrement the counter
911 if self.state == "initial":
912 if self.negotiation_pause:
913 self.negotiation_pause -= 1
915 self.state = "entering_account_name"
917 # show the user a menu as needed
918 elif not self.state == "active":
921 # flush any pending output in the queue
924 # disconnect users with the appropriate state
925 if self.state == "disconnecting":
928 # check for input and add it to the queue
931 # there is input waiting in the queue
933 handle_user_input(self)
936 """Try to send the last item in the queue and remove it."""
937 if self.output_queue:
939 self.connection.send(self.output_queue[0])
940 del self.output_queue[0]
941 except BrokenPipeError:
942 if self.account and self.account.get("name"):
943 account = self.account.get("name")
945 account = "an unknown user"
946 log("Broken pipe sending to %s." % account, 7)
947 self.state = "disconnecting"
949 def enqueue_input(self):
950 """Process and enqueue any new input."""
952 # check for some input
954 raw_input = self.connection.recv(1024)
955 except (BlockingIOError, OSError):
961 # tack this on to any previous partial
962 self.partial_input += raw_input
964 # reply to and remove any IAC negotiation codes
965 mudpy.telnet.negotiate_telnet_options(self)
967 # separate multiple input lines
968 new_input_lines = self.partial_input.split(b"\n")
970 # if input doesn't end in a newline, replace the
971 # held partial input with the last line of it
972 if not self.partial_input.endswith(b"\n"):
973 self.partial_input = new_input_lines.pop()
975 # otherwise, chop off the extra null input and reset
976 # the held partial input
978 new_input_lines.pop()
979 self.partial_input = b""
981 # iterate over the remaining lines
982 for line in new_input_lines:
984 # strip off extra whitespace
987 # log non-printable characters remaining
988 if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_BINARY,
990 asciiline = b"".join(
991 filter(lambda x: b" " <= x <= b"~", line))
992 if line != asciiline:
993 logline = "Non-ASCII characters from "
994 if self.account and self.account.get("name"):
995 logline += self.account.get("name") + ": "
997 logline += "unknown user: "
998 logline += repr(line)
1003 line = line.decode("utf-8")
1004 except UnicodeDecodeError:
1005 logline = "Non-UTF-8 characters from "
1006 if self.account and self.account.get("name"):
1007 logline += self.account.get("name") + ": "
1009 logline += "unknown user: "
1010 logline += repr(line)
1014 line = unicodedata.normalize("NFKC", line)
1016 # put on the end of the queue
1017 self.input_queue.append(line)
1019 def new_avatar(self):
1020 """Instantiate a new, unconfigured avatar for this user."""
1022 while "avatar:" + self.account.get("name") + ":" + str(
1024 ) in universe.categories["actor"].keys():
1026 self.avatar = Element(
1027 "actor:avatar:" + self.account.get("name") + ":" + str(
1032 self.avatar.append("inherit", "archetype:avatar")
1033 self.account.append("avatars", self.avatar.key)
1035 def delete_avatar(self, avatar):
1036 """Remove an avatar from the world and from the user's list."""
1037 if self.avatar is universe.contents[avatar]:
1039 universe.contents[avatar].destroy()
1040 avatars = self.account.getlist("avatars")
1041 avatars.remove(avatar)
1042 self.account.set("avatars", avatars)
1044 def activate_avatar_by_index(self, index):
1045 """Enter the world with a particular indexed avatar."""
1046 self.avatar = universe.contents[
1047 self.account.getlist("avatars")[index]]
1048 self.avatar.owner = self
1049 self.state = "active"
1050 self.avatar.go_home()
1052 def deactivate_avatar(self):
1053 """Have the active avatar leave the world."""
1055 current = self.avatar.get("location")
1057 self.avatar.set("default_location", current)
1058 self.avatar.echo_to_location(
1059 "You suddenly wonder where " + self.avatar.get(
1063 del universe.contents[current].contents[self.avatar.key]
1064 self.avatar.remove_facet("location")
1065 self.avatar.owner = None
1069 """Destroy the user and associated avatars."""
1070 for avatar in self.account.getlist("avatars"):
1071 self.delete_avatar(avatar)
1072 self.account.destroy()
1074 def list_avatar_names(self):
1075 """List names of assigned avatars."""
1077 universe.contents[avatar].get(
1079 ) for avatar in self.account.getlist("avatars")
1083 def broadcast(message, add_prompt=True):
1084 """Send a message to all connected users."""
1085 for each_user in universe.userlist:
1086 each_user.send("$(eol)" + message, add_prompt=add_prompt)
1089 def log(message, level=0):
1090 """Log a message."""
1092 # a couple references we need
1093 file_name = universe.categories["internal"]["logging"].get("file")
1094 max_log_lines = universe.categories["internal"]["logging"].getint(
1097 syslog_name = universe.categories["internal"]["logging"].get("syslog")
1098 timestamp = time.asctime()[4:19]
1100 # turn the message into a list of lines
1102 lambda x: x != "", [(x.rstrip()) for x in message.split("\n")]
1105 # send the timestamp and line to a file
1107 if not os.path.isabs(file_name):
1108 file_name = os.path.join(universe.startdir, file_name)
1109 file_descriptor = codecs.open(file_name, "a", "utf-8")
1111 file_descriptor.write(timestamp + " " + line + "\n")
1112 file_descriptor.flush()
1113 file_descriptor.close()
1115 # send the timestamp and line to standard output
1116 if universe.categories["internal"]["logging"].getboolean("stdout"):
1118 print(timestamp + " " + line)
1120 # send the line to the system log
1123 syslog_name.encode("utf-8"),
1125 syslog.LOG_INFO | syslog.LOG_DAEMON
1131 # display to connected administrators
1132 for user in universe.userlist:
1133 if user.state == "active" and user.account.getboolean(
1135 ) and user.account.getint("loglevel") <= level:
1136 # iterate over every line in the message
1140 "$(bld)$(red)" + timestamp + " "
1141 + line.replace("$(", "$_(") + "$(nrm)$(eol)")
1142 user.send(full_message, flush=True)
1144 # add to the recent log list
1146 while 0 < len(universe.loglines) >= max_log_lines:
1147 del universe.loglines[0]
1148 universe.loglines.append((level, timestamp + " " + line))
1151 def get_loglines(level, start, stop):
1152 """Return a specific range of loglines filtered by level."""
1154 # filter the log lines
1155 loglines = filter(lambda x: x[0] >= level, universe.loglines)
1157 # we need these in several places
1158 total_count = str(len(universe.loglines))
1159 filtered_count = len(loglines)
1161 # don't proceed if there are no lines
1164 # can't start before the begining or at the end
1165 if start > filtered_count:
1166 start = filtered_count
1170 # can't stop before we start
1177 message = "There are " + str(total_count)
1178 message += " log lines in memory and " + str(filtered_count)
1179 message += " at or above level " + str(level) + "."
1180 message += " The matching lines from " + str(stop) + " to "
1181 message += str(start) + " are:$(eol)$(eol)"
1183 # add the text from the selected lines
1185 range_lines = loglines[-start:-(stop - 1)]
1187 range_lines = loglines[-start:]
1188 for line in range_lines:
1189 message += " (" + str(line[0]) + ") " + line[1].replace(
1193 # there were no lines
1195 message = "None of the " + str(total_count)
1196 message += " lines in memory matches your request."
1202 def glyph_columns(character):
1203 """Convenience function to return the column width of a glyph."""
1204 if unicodedata.east_asian_width(character) in "FW":
1210 def wrap_ansi_text(text, width):
1211 """Wrap text with arbitrary width while ignoring ANSI colors."""
1213 # the current position in the entire text string, including all
1214 # characters, printable or otherwise
1217 # the current text position relative to the begining of the line,
1218 # ignoring color escape sequences
1221 # the absolute position of the most recent whitespace character
1224 # whether the current character is part of a color escape sequence
1227 # normalize any potentially composited unicode before we count it
1228 text = unicodedata.normalize("NFKC", text)
1230 # iterate over each character from the begining of the text
1231 for each_character in text:
1233 # the current character is the escape character
1234 if each_character == "\x1b" and not escape:
1237 # the current character is within an escape sequence
1240 # the current character is m, which terminates the
1242 if each_character == "m":
1245 # the current character is a newline, so reset the relative
1246 # position (start a new line)
1247 elif each_character == "\n":
1249 last_whitespace = abs_pos
1251 # the current character meets the requested maximum line width,
1252 # so we need to backtrack and find a space at which to wrap;
1253 # special care is taken to avoid an off-by-one in case the
1254 # current character is a double-width glyph
1255 elif each_character != "\r" and (
1256 rel_pos >= width or (
1257 rel_pos >= width - 1 and glyph_columns(
1263 # it's always possible we landed on whitespace
1264 if unicodedata.category(each_character) in ("Cc", "Zs"):
1265 last_whitespace = abs_pos
1267 # insert an eol in place of the space
1268 text = text[:last_whitespace] + "\r\n" + text[last_whitespace + 1:]
1270 # increase the absolute position because an eol is two
1271 # characters but the space it replaced was only one
1274 # now we're at the begining of a new line, plus the
1275 # number of characters wrapped from the previous line
1277 for remaining_characters in text[last_whitespace:abs_pos]:
1278 rel_pos += glyph_columns(remaining_characters)
1280 # as long as the character is not a carriage return and the
1281 # other above conditions haven't been met, count it as a
1282 # printable character
1283 elif each_character != "\r":
1284 rel_pos += glyph_columns(each_character)
1285 if unicodedata.category(each_character) in ("Cc", "Zs"):
1286 last_whitespace = abs_pos
1288 # increase the absolute position for every character
1291 # return the newly-wrapped text
1295 def weighted_choice(data):
1296 """Takes a dict weighted by value and returns a random key."""
1298 # this will hold our expanded list of keys from the data
1301 # create the expanded list of keys
1302 for key in data.keys():
1303 for count in range(data[key]):
1304 expanded.append(key)
1306 # return one at random
1307 return random.choice(expanded)
1311 """Returns a random character name."""
1313 # the vowels and consonants needed to create romaji syllables
1342 # this dict will hold our weighted list of syllables
1345 # generate the list with an even weighting
1346 for consonant in consonants:
1347 for vowel in vowels:
1348 syllables[consonant + vowel] = 1
1350 # we'll build the name into this string
1353 # create a name of random length from the syllables
1354 for syllable in range(random.randrange(2, 6)):
1355 name += weighted_choice(syllables)
1357 # strip any leading quotemark, capitalize and return the name
1358 return name.strip("'").capitalize()
1361 def replace_macros(user, text, is_input=False):
1362 """Replaces macros in text output."""
1364 # third person pronouns
1366 "female": {"obj": "her", "pos": "hers", "sub": "she"},
1367 "male": {"obj": "him", "pos": "his", "sub": "he"},
1368 "neuter": {"obj": "it", "pos": "its", "sub": "it"}
1371 # a dict of replacement macros
1374 "bld": chr(27) + "[1m",
1375 "nrm": chr(27) + "[0m",
1376 "blk": chr(27) + "[30m",
1377 "blu": chr(27) + "[34m",
1378 "cyn": chr(27) + "[36m",
1379 "grn": chr(27) + "[32m",
1380 "mgt": chr(27) + "[35m",
1381 "red": chr(27) + "[31m",
1382 "yel": chr(27) + "[33m",
1385 # add dynamic macros where possible
1387 account_name = user.account.get("name")
1389 macros["account"] = account_name
1391 avatar_gender = user.avatar.get("gender")
1393 macros["tpop"] = pronouns[avatar_gender]["obj"]
1394 macros["tppp"] = pronouns[avatar_gender]["pos"]
1395 macros["tpsp"] = pronouns[avatar_gender]["sub"]
1400 # find and replace per the macros dict
1401 macro_start = text.find("$(")
1402 if macro_start == -1:
1404 macro_end = text.find(")", macro_start) + 1
1405 macro = text[macro_start + 2:macro_end - 1]
1406 if macro in macros.keys():
1407 replacement = macros[macro]
1409 # this is how we handle local file inclusion (dangerous!)
1410 elif macro.startswith("inc:"):
1411 incfile = mudpy.data.find_file(macro[4:], universe=universe)
1412 if os.path.exists(incfile):
1413 incfd = codecs.open(incfile, "r", "utf-8")
1416 if line.endswith("\n") and not line.endswith("\r\n"):
1417 line = line.replace("\n", "\r\n")
1419 # lose the trailing eol
1420 replacement = replacement[:-2]
1423 log("Couldn't read included " + incfile + " file.", 6)
1425 # if we get here, log and replace it with null
1429 log("Unexpected replacement macro " +
1430 macro + " encountered.", 6)
1432 # and now we act on the replacement
1433 text = text.replace("$(" + macro + ")", replacement)
1435 # replace the look-like-a-macro sequence
1436 text = text.replace("$_(", "$(")
1441 def escape_macros(text):
1442 """Escapes replacement macros in text."""
1443 return text.replace("$(", "$_(")
1446 def first_word(text, separator=" "):
1447 """Returns a tuple of the first word and the rest."""
1449 if text.find(separator) > 0:
1450 return text.split(separator, 1)
1458 """The things which should happen on each pulse, aside from reloads."""
1460 # open the listening socket if it hasn't been already
1461 if not hasattr(universe, "listening_socket"):
1462 universe.initialize_server_socket()
1464 # assign a user if a new connection is waiting
1465 user = check_for_connection(universe.listening_socket)
1467 universe.userlist.append(user)
1469 # iterate over the connected users
1470 for user in universe.userlist:
1473 # add an element for counters if it doesn't exist
1474 if "counters" not in universe.categories["internal"]:
1475 universe.categories["internal"]["counters"] = Element(
1476 "internal:counters", universe
1479 # update the log every now and then
1480 if not universe.categories["internal"]["counters"].getint("mark"):
1481 log(str(len(universe.userlist)) + " connection(s)")
1482 universe.categories["internal"]["counters"].set(
1483 "mark", universe.categories["internal"]["time"].getint(
1488 universe.categories["internal"]["counters"].set(
1489 "mark", universe.categories["internal"]["counters"].getint(
1494 # periodically save everything
1495 if not universe.categories["internal"]["counters"].getint("save"):
1497 universe.categories["internal"]["counters"].set(
1498 "save", universe.categories["internal"]["time"].getint(
1503 universe.categories["internal"]["counters"].set(
1504 "save", universe.categories["internal"]["counters"].getint(
1509 # pause for a configurable amount of time (decimal seconds)
1510 time.sleep(universe.categories["internal"]
1511 ["time"].getfloat("increment"))
1513 # increase the elapsed increment counter
1514 universe.categories["internal"]["counters"].set(
1515 "elapsed", universe.categories["internal"]["counters"].getint(
1522 """Reload all relevant objects."""
1523 for user in universe.userlist[:]:
1525 for element in universe.contents.values():
1526 if element.origin.is_writeable():
1531 def check_for_connection(listening_socket):
1532 """Check for a waiting connection and return a new user object."""
1534 # try to accept a new connection
1536 connection, address = listening_socket.accept()
1537 except BlockingIOError:
1540 # note that we got one
1541 log("Connection from " + address[0], 2)
1543 # disable blocking so we can proceed whether or not we can send/receive
1544 connection.setblocking(0)
1546 # create a new user object
1549 # associate this connection with it
1550 user.connection = connection
1552 # set the user's ipa from the connection's ipa
1553 user.address = address[0]
1555 # let the client know we WILL EOR (RFC 885)
1556 mudpy.telnet.enable(user, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US)
1557 user.negotiation_pause = 2
1559 # return the new user object
1563 def get_menu(state, error=None, choices=None):
1564 """Show the correct menu text to a user."""
1566 # make sure we don't reuse a mutable sequence by default
1570 # get the description or error text
1571 message = get_menu_description(state, error)
1573 # get menu choices for the current state
1574 message += get_formatted_menu_choices(state, choices)
1576 # try to get a prompt, if it was defined
1577 message += get_menu_prompt(state)
1579 # throw in the default choice, if it exists
1580 message += get_formatted_default_menu_choice(state)
1582 # display a message indicating if echo is off
1583 message += get_echo_message(state)
1585 # return the assembly of various strings defined above
1589 def menu_echo_on(state):
1590 """True if echo is on, false if it is off."""
1591 return universe.categories["menu"][state].getboolean("echo", True)
1594 def get_echo_message(state):
1595 """Return a message indicating that echo is off."""
1596 if menu_echo_on(state):
1599 return "(won't echo) "
1602 def get_default_menu_choice(state):
1603 """Return the default choice for a menu."""
1604 return universe.categories["menu"][state].get("default")
1607 def get_formatted_default_menu_choice(state):
1608 """Default menu choice foratted for inclusion in a prompt string."""
1609 default_choice = get_default_menu_choice(state)
1611 return "[$(red)" + default_choice + "$(nrm)] "
1616 def get_menu_description(state, error):
1617 """Get the description or error text."""
1619 # an error condition was raised by the handler
1622 # try to get an error message matching the condition
1624 description = universe.categories[
1625 "menu"][state].get("error_" + error)
1627 description = "That is not a valid choice..."
1628 description = "$(red)" + description + "$(nrm)"
1630 # there was no error condition
1633 # try to get a menu description for the current state
1634 description = universe.categories["menu"][state].get("description")
1636 # return the description or error message
1638 description += "$(eol)$(eol)"
1642 def get_menu_prompt(state):
1643 """Try to get a prompt, if it was defined."""
1644 prompt = universe.categories["menu"][state].get("prompt")
1650 def get_menu_choices(user):
1651 """Return a dict of choice:meaning."""
1652 menu = universe.categories["menu"][user.state]
1653 create_choices = menu.get("create")
1655 choices = eval(create_choices)
1661 for facet in menu.facets():
1662 if facet.startswith("demand_") and not eval(
1663 universe.categories["menu"][user.state].get(facet)
1665 ignores.append(facet.split("_", 2)[1])
1666 elif facet.startswith("create_"):
1667 creates[facet] = facet.split("_", 2)[1]
1668 elif facet.startswith("choice_"):
1669 options[facet] = facet.split("_", 2)[1]
1670 for facet in creates.keys():
1671 if not creates[facet] in ignores:
1672 choices[creates[facet]] = eval(menu.get(facet))
1673 for facet in options.keys():
1674 if not options[facet] in ignores:
1675 choices[options[facet]] = menu.get(facet)
1679 def get_formatted_menu_choices(state, choices):
1680 """Returns a formatted string of menu choices."""
1682 choice_keys = list(choices.keys())
1684 for choice in choice_keys:
1685 choice_output += " [$(red)" + choice + "$(nrm)] " + choices[
1689 choice_output += "$(eol)"
1690 return choice_output
1693 def get_menu_branches(state):
1694 """Return a dict of choice:branch."""
1696 for facet in universe.categories["menu"][state].facets():
1697 if facet.startswith("branch_"):
1699 facet.split("_", 2)[1]
1700 ] = universe.categories["menu"][state].get(facet)
1704 def get_default_branch(state):
1705 """Return the default branch."""
1706 return universe.categories["menu"][state].get("branch")
1709 def get_choice_branch(user, choice):
1710 """Returns the new state matching the given choice."""
1711 branches = get_menu_branches(user.state)
1712 if choice in branches.keys():
1713 return branches[choice]
1714 elif choice in user.menu_choices.keys():
1715 return get_default_branch(user.state)
1720 def get_menu_actions(state):
1721 """Return a dict of choice:branch."""
1723 for facet in universe.categories["menu"][state].facets():
1724 if facet.startswith("action_"):
1726 facet.split("_", 2)[1]
1727 ] = universe.categories["menu"][state].get(facet)
1731 def get_default_action(state):
1732 """Return the default action."""
1733 return universe.categories["menu"][state].get("action")
1736 def get_choice_action(user, choice):
1737 """Run any indicated script for the given choice."""
1738 actions = get_menu_actions(user.state)
1739 if choice in actions.keys():
1740 return actions[choice]
1741 elif choice in user.menu_choices.keys():
1742 return get_default_action(user.state)
1747 def handle_user_input(user):
1748 """The main handler, branches to a state-specific handler."""
1750 # if the user's client echo is off, send a blank line for aesthetics
1751 if mudpy.telnet.is_enabled(user, mudpy.telnet.TELOPT_ECHO,
1753 user.send("", add_prompt=False, prepend_padding=False)
1755 # check to make sure the state is expected, then call that handler
1756 if "handler_" + user.state in globals():
1757 exec("handler_" + user.state + "(user)")
1759 generic_menu_handler(user)
1761 # since we got input, flag that the menu/prompt needs to be redisplayed
1762 user.menu_seen = False
1764 # update the last_input timestamp while we're at it
1765 user.last_input = universe.get_time()
1768 def generic_menu_handler(user):
1769 """A generic menu choice handler."""
1771 # get a lower-case representation of the next line of input
1772 if user.input_queue:
1773 choice = user.input_queue.pop(0)
1775 choice = choice.lower()
1779 choice = get_default_menu_choice(user.state)
1780 if choice in user.menu_choices:
1781 exec(get_choice_action(user, choice))
1782 new_state = get_choice_branch(user, choice)
1784 user.state = new_state
1786 user.error = "default"
1789 def handler_entering_account_name(user):
1790 """Handle the login account name."""
1792 # get the next waiting line of input
1793 input_data = user.input_queue.pop(0)
1795 # did the user enter anything?
1798 # keep only the first word and convert to lower-case
1799 name = input_data.lower()
1801 # fail if there are non-alphanumeric characters
1802 if name != "".join(filter(
1803 lambda x: x >= "0" and x <= "9" or x >= "a" and x <= "z",
1805 user.error = "bad_name"
1807 # if that account exists, time to request a password
1808 elif name in universe.categories["account"]:
1809 user.account = universe.categories["account"][name]
1810 user.state = "checking_password"
1812 # otherwise, this could be a brand new user
1814 user.account = Element("account:" + name, universe)
1815 user.account.set("name", name)
1816 log("New user: " + name, 2)
1817 user.state = "checking_new_account_name"
1819 # if the user entered nothing for a name, then buhbye
1821 user.state = "disconnecting"
1824 def handler_checking_password(user):
1825 """Handle the login account password."""
1827 # get the next waiting line of input
1828 input_data = user.input_queue.pop(0)
1830 # does the hashed input equal the stored hash?
1831 if mudpy.password.verify(input_data, user.account.get("passhash")):
1833 # if so, set the username and load from cold storage
1834 if not user.replace_old_connections():
1836 user.state = "main_utility"
1838 # if at first your hashes don't match, try, try again
1839 elif user.password_tries < universe.categories[
1846 user.password_tries += 1
1847 user.error = "incorrect"
1849 # we've exceeded the maximum number of password failures, so disconnect
1852 "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1854 user.state = "disconnecting"
1857 def handler_entering_new_password(user):
1858 """Handle a new password entry."""
1860 # get the next waiting line of input
1861 input_data = user.input_queue.pop(0)
1863 # make sure the password is strong--at least one upper, one lower and
1864 # one digit, seven or more characters in length
1865 if len(input_data) > 6 and len(
1866 list(filter(lambda x: x >= "0" and x <= "9", input_data))
1868 list(filter(lambda x: x >= "A" and x <= "Z", input_data))
1870 list(filter(lambda x: x >= "a" and x <= "z", input_data))
1873 # hash and store it, then move on to verification
1874 user.account.set("passhash", mudpy.password.create(input_data))
1875 user.state = "verifying_new_password"
1877 # the password was weak, try again if you haven't tried too many times
1878 elif user.password_tries < universe.categories[
1885 user.password_tries += 1
1888 # too many tries, so adios
1891 "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1893 user.account.destroy()
1894 user.state = "disconnecting"
1897 def handler_verifying_new_password(user):
1898 """Handle the re-entered new password for verification."""
1900 # get the next waiting line of input
1901 input_data = user.input_queue.pop(0)
1903 # hash the input and match it to storage
1904 if mudpy.password.verify(input_data, user.account.get("passhash")):
1907 # the hashes matched, so go active
1908 if not user.replace_old_connections():
1909 user.state = "main_utility"
1911 # go back to entering the new password as long as you haven't tried
1913 elif user.password_tries < universe.categories[
1920 user.password_tries += 1
1921 user.error = "differs"
1922 user.state = "entering_new_password"
1924 # otherwise, sayonara
1927 "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1929 user.account.destroy()
1930 user.state = "disconnecting"
1933 def handler_active(user):
1934 """Handle input for active users."""
1936 # get the next waiting line of input
1937 input_data = user.input_queue.pop(0)
1942 # split out the command and parameters
1944 mode = actor.get("mode")
1945 if mode and input_data.startswith("!"):
1946 command_name, parameters = first_word(input_data[1:])
1947 elif mode == "chat":
1948 command_name = "say"
1949 parameters = input_data
1951 command_name, parameters = first_word(input_data)
1953 # lowercase the command
1954 command_name = command_name.lower()
1956 # the command matches a command word for which we have data
1957 if command_name in universe.categories["command"]:
1958 command = universe.categories["command"][command_name]
1962 # if it's allowed, do it
1963 if actor.can_run(command):
1964 exec(command.get("action"))
1966 # otherwise, give an error
1968 command_error(actor, input_data)
1970 # if no input, just idle back with a prompt
1972 user.send("", just_prompt=True)
1975 def command_halt(actor, parameters):
1976 """Halt the world."""
1979 # see if there's a message or use a generic one
1981 message = "Halting: " + parameters
1983 message = "User " + actor.owner.account.get(
1985 ) + " halted the world."
1988 broadcast(message, add_prompt=False)
1991 # set a flag to terminate the world
1992 universe.terminate_flag = True
1995 def command_reload(actor):
1996 """Reload all code modules, configs and data."""
1999 # let the user know and log
2000 actor.send("Reloading all code modules, configs and data.")
2003 actor.owner.account.get("name") + " reloaded the world.",
2007 # set a flag to reload
2008 universe.reload_flag = True
2011 def command_quit(actor):
2012 """Leave the world and go back to the main menu."""
2014 actor.owner.state = "main_utility"
2015 actor.owner.deactivate_avatar()
2018 def command_help(actor, parameters):
2019 """List available commands and provide help for commands."""
2021 # did the user ask for help on a specific command word?
2022 if parameters and actor.owner:
2024 # is the command word one for which we have data?
2025 if parameters in universe.categories["command"]:
2026 command = universe.categories["command"][parameters]
2030 # only for allowed commands
2031 if actor.can_run(command):
2033 # add a description if provided
2034 description = command.get("description")
2036 description = "(no short description provided)"
2037 if command.getboolean("administrative"):
2041 output += parameters + "$(nrm) - " + description + "$(eol)$(eol)"
2043 # add the help text if provided
2044 help_text = command.get("help")
2046 help_text = "No help is provided for this command."
2049 # list related commands
2050 see_also = command.getlist("see_also")
2052 really_see_also = ""
2053 for item in see_also:
2054 if item in universe.categories["command"]:
2055 command = universe.categories["command"][item]
2056 if actor.can_run(command):
2058 really_see_also += ", "
2059 if command.getboolean("administrative"):
2060 really_see_also += "$(red)"
2062 really_see_also += "$(grn)"
2063 really_see_also += item + "$(nrm)"
2065 output += "$(eol)$(eol)See also: " + really_see_also
2067 # no data for the requested command word
2069 output = "That is not an available command."
2071 # no specific command word was indicated
2074 # give a sorted list of commands with descriptions if provided
2075 output = "These are the commands available to you:$(eol)$(eol)"
2076 sorted_commands = list(universe.categories["command"].keys())
2077 sorted_commands.sort()
2078 for item in sorted_commands:
2079 command = universe.categories["command"][item]
2080 if actor.can_run(command):
2081 description = command.get("description")
2083 description = "(no short description provided)"
2084 if command.getboolean("administrative"):
2088 output += item + "$(nrm) - " + description + "$(eol)"
2089 output += ("$(eol)Enter \"help COMMAND\" for help on a command "
2090 "named \"COMMAND\".")
2092 # send the accumulated output to the user
2096 def command_move(actor, parameters):
2097 """Move the avatar in a given direction."""
2098 if parameters in universe.contents[actor.get("location")].portals():
2099 actor.move_direction(parameters)
2101 actor.send("You cannot go that way.")
2104 def command_look(actor, parameters):
2107 actor.send("You can't look at or in anything yet.")
2109 actor.look_at(actor.get("location"))
2112 def command_say(actor, parameters):
2113 """Speak to others in the same area."""
2115 # check for replacement macros and escape them
2116 parameters = escape_macros(parameters)
2118 # if the message is wrapped in quotes, remove them and leave contents
2120 if parameters.startswith("\"") and parameters.endswith("\""):
2121 message = parameters[1:-1]
2124 # otherwise, get rid of stray quote marks on the ends of the message
2126 message = parameters.strip("\"'`")
2129 # the user entered a message
2132 # match the punctuation used, if any, to an action
2133 actions = universe.categories["internal"]["language"].getdict(
2136 default_punctuation = (
2137 universe.categories["internal"]["language"].get(
2138 "default_punctuation"))
2140 for mark in actions.keys():
2141 if not literal and message.endswith(mark):
2142 action = actions[mark]
2145 # add punctuation if needed
2147 action = actions[default_punctuation]
2148 if message and not (
2149 literal or unicodedata.category(message[-1]) == "Po"
2151 message += default_punctuation
2153 # failsafe checks to avoid unwanted reformatting and null strings
2154 if message and not literal:
2156 # decapitalize the first letter to improve matching
2157 message = message[0].lower() + message[1:]
2159 # iterate over all words in message, replacing typos
2160 typos = universe.categories["internal"]["language"].getdict(
2163 words = message.split()
2164 for index in range(len(words)):
2166 while unicodedata.category(word[0]) == "Po":
2168 while unicodedata.category(word[-1]) == "Po":
2170 if word in typos.keys():
2171 words[index] = words[index].replace(word, typos[word])
2172 message = " ".join(words)
2174 # capitalize the first letter
2175 message = message[0].upper() + message[1:]
2179 actor.echo_to_location(
2180 actor.get("name") + " " + action + "s, \"" + message + "\""
2182 actor.send("You " + action + ", \"" + message + "\"")
2184 # there was no message
2186 actor.send("What do you want to say?")
2189 def command_chat(actor):
2190 """Toggle chat mode."""
2191 mode = actor.get("mode")
2193 actor.set("mode", "chat")
2194 actor.send("Entering chat mode (use $(grn)!chat$(nrm) to exit).")
2195 elif mode == "chat":
2196 actor.remove_facet("mode")
2197 actor.send("Exiting chat mode.")
2199 actor.send("Sorry, but you're already busy with something else!")
2202 def command_show(actor, parameters):
2203 """Show program data."""
2205 arguments = parameters.split()
2207 message = "What do you want to show?"
2208 elif arguments[0] == "time":
2209 message = universe.categories["internal"]["counters"].get(
2211 ) + " increments elapsed since the world was created."
2212 elif arguments[0] == "categories":
2213 message = "These are the element categories:$(eol)"
2214 categories = list(universe.categories.keys())
2216 for category in categories:
2217 message += "$(eol) $(grn)" + category + "$(nrm)"
2218 elif arguments[0] == "files":
2219 message = "These are the current files containing the universe:$(eol)"
2220 filenames = list(universe.files.keys())
2222 for filename in filenames:
2223 if universe.files[filename].is_writeable():
2227 message += ("$(eol) $(red)(" + status + ") $(grn)" + filename
2229 elif arguments[0] == "category":
2230 if len(arguments) != 2:
2231 message = "You must specify one category."
2232 elif arguments[1] in universe.categories:
2233 message = ("These are the elements in the \"" + arguments[1]
2234 + "\" category:$(eol)")
2237 universe.categories[arguments[1]][x].key
2238 ) for x in universe.categories[arguments[1]].keys()
2241 for element in elements:
2242 message += "$(eol) $(grn)" + element + "$(nrm)"
2244 message = "Category \"" + arguments[1] + "\" does not exist."
2245 elif arguments[0] == "file":
2246 if len(arguments) != 2:
2247 message = "You must specify one file."
2248 elif arguments[1] in universe.files:
2249 message = ("These are the elements in the \"" + arguments[1]
2251 elements = universe.files[arguments[1]].data.sections()
2253 for element in elements:
2254 message += "$(eol) $(grn)" + element + "$(nrm)"
2256 message = "Category \"" + arguments[1] + "\" does not exist."
2257 elif arguments[0] == "element":
2258 if len(arguments) != 2:
2259 message = "You must specify one element."
2260 elif arguments[1] in universe.contents:
2261 element = universe.contents[arguments[1]]
2262 message = ("These are the properties of the \"" + arguments[1]
2263 + "\" element (in \"" + element.origin.filename
2265 facets = element.facets()
2267 for facet in facets:
2268 message += ("$(eol) $(grn)" + facet + ": $(red)"
2269 + escape_macros(element.get(facet)) + "$(nrm)")
2271 message = "Element \"" + arguments[1] + "\" does not exist."
2272 elif arguments[0] == "result":
2273 if len(arguments) < 2:
2274 message = "You need to specify an expression."
2277 message = repr(eval(" ".join(arguments[1:])))
2278 except Exception as e:
2279 message = ("$(red)Your expression raised an exception...$(eol)"
2280 "$(eol)$(bld)%s$(nrm)" % e)
2281 elif arguments[0] == "log":
2282 if len(arguments) == 4:
2283 if re.match("^\d+$", arguments[3]) and int(arguments[3]) >= 0:
2284 stop = int(arguments[3])
2289 if len(arguments) >= 3:
2290 if re.match("^\d+$", arguments[2]) and int(arguments[2]) > 0:
2291 start = int(arguments[2])
2296 if len(arguments) >= 2:
2297 if (re.match("^\d+$", arguments[1])
2298 and 0 <= int(arguments[1]) <= 9):
2299 level = int(arguments[1])
2302 elif 0 <= actor.owner.account.getint("loglevel") <= 9:
2303 level = actor.owner.account.getint("loglevel")
2306 if level > -1 and start > -1 and stop > -1:
2307 message = get_loglines(level, start, stop)
2309 message = ("When specified, level must be 0-9 (default 1), "
2310 "start and stop must be >=1 (default 10 and 1).")
2312 message = "I don't know what \"" + parameters + "\" is."
2316 def command_create(actor, parameters):
2317 """Create an element if it does not exist."""
2319 message = "You must at least specify an element to create."
2320 elif not actor.owner:
2323 arguments = parameters.split()
2324 if len(arguments) == 1:
2325 arguments.append("")
2326 if len(arguments) == 2:
2327 element, filename = arguments
2328 if element in universe.contents:
2329 message = "The \"" + element + "\" element already exists."
2331 message = ("You create \"" +
2332 element + "\" within the universe.")
2333 logline = actor.owner.account.get(
2335 ) + " created an element: " + element
2337 logline += " in file " + filename
2338 if filename not in universe.files:
2340 " Warning: \"" + filename + "\" is not yet "
2341 "included in any other file and will not be read "
2342 "on startup unless this is remedied.")
2343 Element(element, universe, filename)
2345 elif len(arguments) > 2:
2346 message = "You can only specify an element and a filename."
2350 def command_destroy(actor, parameters):
2351 """Destroy an element if it exists."""
2354 message = "You must specify an element to destroy."
2356 if parameters not in universe.contents:
2357 message = "The \"" + parameters + "\" element does not exist."
2359 universe.contents[parameters].destroy()
2360 message = ("You destroy \"" + parameters
2361 + "\" within the universe.")
2363 actor.owner.account.get(
2365 ) + " destroyed an element: " + parameters,
2371 def command_set(actor, parameters):
2372 """Set a facet of an element."""
2374 message = "You must specify an element, a facet and a value."
2376 arguments = parameters.split(" ", 2)
2377 if len(arguments) == 1:
2378 message = ("What facet of element \"" + arguments[0]
2379 + "\" would you like to set?")
2380 elif len(arguments) == 2:
2381 message = ("What value would you like to set for the \"" +
2382 arguments[1] + "\" facet of the \"" + arguments[0]
2385 element, facet, value = arguments
2386 if element not in universe.contents:
2387 message = "The \"" + element + "\" element does not exist."
2389 universe.contents[element].set(facet, value)
2390 message = ("You have successfully (re)set the \"" + facet
2391 + "\" facet of element \"" + element
2392 + "\". Try \"show element " +
2393 element + "\" for verification.")
2397 def command_delete(actor, parameters):
2398 """Delete a facet from an element."""
2400 message = "You must specify an element and a facet."
2402 arguments = parameters.split(" ")
2403 if len(arguments) == 1:
2404 message = ("What facet of element \"" + arguments[0]
2405 + "\" would you like to delete?")
2406 elif len(arguments) != 2:
2407 message = "You may only specify an element and a facet."
2409 element, facet = arguments
2410 if element not in universe.contents:
2411 message = "The \"" + element + "\" element does not exist."
2412 elif facet not in universe.contents[element].facets():
2413 message = ("The \"" + element + "\" element has no \"" + facet
2416 universe.contents[element].remove_facet(facet)
2417 message = ("You have successfully deleted the \"" + facet
2418 + "\" facet of element \"" + element
2419 + "\". Try \"show element " +
2420 element + "\" for verification.")
2424 def command_error(actor, input_data):
2425 """Generic error for an unrecognized command word."""
2427 # 90% of the time use a generic error
2428 if random.randrange(10):
2429 message = "I'm not sure what \"" + input_data + "\" means..."
2431 # 10% of the time use the classic diku error
2433 message = "Arglebargle, glop-glyf!?!"
2435 # send the error message
2439 def daemonize(universe):
2440 """Fork and disassociate from everything."""
2442 # only if this is what we're configured to do
2443 if universe.contents["internal:process"].getboolean("daemon"):
2445 # log before we start forking around, so the terminal gets the message
2446 log("Disassociating from the controlling terminal.")
2448 # fork off and die, so we free up the controlling terminal
2452 # switch to a new process group
2455 # fork some more, this time to free us from the old process group
2459 # reset the working directory so we don't needlessly tie up mounts
2462 # clear the file creation mask so we can bend it to our will later
2465 # redirect stdin/stdout/stderr and close off their former descriptors
2466 for stdpipe in range(3):
2468 sys.stdin = codecs.open("/dev/null", "r", "utf-8")
2469 sys.__stdin__ = codecs.open("/dev/null", "r", "utf-8")
2470 sys.stdout = codecs.open("/dev/null", "w", "utf-8")
2471 sys.stderr = codecs.open("/dev/null", "w", "utf-8")
2472 sys.__stdout__ = codecs.open("/dev/null", "w", "utf-8")
2473 sys.__stderr__ = codecs.open("/dev/null", "w", "utf-8")
2476 def create_pidfile(universe):
2477 """Write a file containing the current process ID."""
2478 pid = str(os.getpid())
2479 log("Process ID: " + pid)
2480 file_name = universe.contents["internal:process"].get("pidfile")
2482 if not os.path.isabs(file_name):
2483 file_name = os.path.join(universe.startdir, file_name)
2484 file_descriptor = codecs.open(file_name, "w", "utf-8")
2485 file_descriptor.write(pid + "\n")
2486 file_descriptor.flush()
2487 file_descriptor.close()
2490 def remove_pidfile(universe):
2491 """Remove the file containing the current process ID."""
2492 file_name = universe.contents["internal:process"].get("pidfile")
2494 if not os.path.isabs(file_name):
2495 file_name = os.path.join(universe.startdir, file_name)
2496 if os.access(file_name, os.W_OK):
2497 os.remove(file_name)
2500 def excepthook(excepttype, value, tracebackdata):
2501 """Handle uncaught exceptions."""
2503 # assemble the list of errors into a single string
2505 traceback.format_exception(excepttype, value, tracebackdata)
2508 # try to log it, if possible
2511 except Exception as e:
2512 # try to write it to stderr, if possible
2513 sys.stderr.write(message + "\nException while logging...\n%s" % e)
2516 def sighook(what, where):
2517 """Handle external signals."""
2520 message = "Caught signal: "
2522 # for a hangup signal
2523 if what == signal.SIGHUP:
2524 message += "hangup (reloading)"
2525 universe.reload_flag = True
2527 # for a terminate signal
2528 elif what == signal.SIGTERM:
2529 message += "terminate (halting)"
2530 universe.terminate_flag = True
2532 # catchall for unexpected signals
2534 message += str(what) + " (unhandled)"
2540 def override_excepthook():
2541 """Redefine sys.excepthook with our own."""
2542 sys.excepthook = excepthook
2545 def assign_sighook():
2546 """Assign a customized handler for some signals."""
2547 signal.signal(signal.SIGHUP, sighook)
2548 signal.signal(signal.SIGTERM, sighook)
2552 """This contains functions to be performed when starting the engine."""
2554 # see if a configuration file was specified
2555 if len(sys.argv) > 1:
2556 conffile = sys.argv[1]
2562 universe = Universe(conffile, True)
2564 # log an initial message
2565 log("Started mudpy with command line: " + " ".join(sys.argv))
2567 # fork and disassociate
2570 # override the default exception handler so we get logging first thing
2571 override_excepthook()
2573 # set up custom signal handlers
2577 create_pidfile(universe)
2579 # pass the initialized universe back
2584 """These are functions performed when shutting down the engine."""
2586 # the loop has terminated, so save persistent data
2589 # log a final message
2590 log("Shutting down now.")
2592 # get rid of the pidfile
2593 remove_pidfile(universe)