1 """Miscellaneous functions for the mudpy engine."""
3 # Copyright (c) 2004-2021 mudpy authors. Permission to use, copy,
4 # modify, and distribute this software is granted under terms
5 # provided in the LICENSE file distributed with this software.
25 """An element of the universe."""
27 def __init__(self, key, universe, origin=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 # set of facet keys from the universe
37 self.facethash = dict()
39 # not owned by a user by default (used for avatars)
42 # no contents in here by default
45 if self.key.find(".") > 0:
46 self.group, self.subkey = self.key.split(".")[-2:]
49 self.subkey = self.key
50 if self.group not in self.universe.groups:
51 self.universe.groups[self.group] = {}
53 # get an appropriate origin
55 self.universe.add_group(self.group)
56 origin = self.universe.files[
57 self.universe.origins[self.group]["fallback"]]
59 # record or reset a pointer to the origin file
60 self.origin = self.universe.files[origin.source]
62 # add or replace this element in the universe
63 self.universe.contents[self.key] = self
64 self.universe.groups[self.group][self.subkey] = self
67 """Create a new element and replace this one."""
68 args = (self.key, self.universe, self.origin)
73 """Remove an element from the universe and destroy it."""
74 for facet in dict(self.facethash):
75 self.remove_facet(facet)
76 del self.universe.groups[self.group][self.subkey]
77 del self.universe.contents[self.key]
81 """Return a list of non-inherited facets for this element."""
84 def has_facet(self, facet):
85 """Return whether the non-inherited facet exists."""
86 return facet in self.facets()
88 def remove_facet(self, facet):
89 """Remove a facet from the element."""
90 if ".".join((self.key, facet)) in self.origin.data:
91 del self.origin.data[".".join((self.key, facet))]
92 if facet in self.facethash:
93 del self.facethash[facet]
94 self.origin.modified = True
97 """Return a list of the element's inheritance lineage."""
98 if self.has_facet("inherit"):
99 ancestry = self.get("inherit")
102 for parent in ancestry[:]:
103 ancestors = self.universe.contents[parent].ancestry()
104 for ancestor in ancestors:
105 if ancestor not in ancestry:
106 ancestry.append(ancestor)
111 def get(self, facet, default=None):
112 """Retrieve values."""
116 return self.origin.data[".".join((self.key, facet))]
117 except (KeyError, TypeError):
119 if self.has_facet("inherit"):
120 for ancestor in self.ancestry():
121 if self.universe.contents[ancestor].has_facet(facet):
122 return self.universe.contents[ancestor].get(facet)
126 def set(self, facet, value):
128 if not self.origin.is_writeable() and not self.universe.loading:
129 # break if there is an attempt to update an element from a
130 # read-only file, unless the universe is in the midst of loading
131 # updated data from files
132 raise PermissionError("Altering elements in read-only files is "
134 # Coerce some values to appropriate data types
135 # TODO(fungi) Move these to a separate validation mechanism
136 if facet in ["loglevel"]:
138 elif facet in ["administrator"]:
141 # The canonical node for this facet within its origin
142 node = ".".join((self.key, facet))
144 if node not in self.origin.data or self.origin.data[node] != value:
145 # Be careful to only update the origin's contents when required,
146 # since that affects whether the backing file gets written
147 self.origin.data[node] = value
148 self.origin.modified = True
150 # Make sure this facet is included in the element's facets
151 self.facethash[facet] = self.origin.data[node]
153 def append(self, facet, value):
154 """Append value to a list."""
155 newlist = self.get(facet)
158 if type(newlist) is not list:
159 newlist = list(newlist)
160 newlist.append(value)
161 self.set(facet, newlist)
171 add_terminator=False,
174 """Convenience method to pass messages to an owner."""
187 def is_restricted(self):
188 """Boolean check whether command is administrative or debugging."""
189 return bool(self.get("administrative") or self.get("debugging"))
192 """Boolean check whether an actor is controlled by an admin owner."""
193 return self.owner and self.owner.is_admin()
195 def can_run(self, command):
196 """Check if the user can run this command object."""
198 # has to be in the commands group
199 if command not in self.universe.groups["command"].values():
202 # debugging commands are not allowed outside debug mode
203 if command.get("debugging") and not self.universe.debug_mode():
206 # avatars of administrators can run any command
210 # everyone can run non-administrative commands
211 if not command.is_restricted():
214 # otherwise the command cannot be run by this actor
217 def update_location(self):
218 """Make sure the location's contents contain this element."""
219 area = self.get("location")
220 if area in self.universe.contents:
221 self.universe.contents[area].contents[self.key] = self
223 def clean_contents(self):
224 """Make sure the element's contents aren't bogus."""
225 for element in self.contents.values():
226 if element.get("location") != self.key:
227 del self.contents[element.key]
229 def go_to(self, area):
230 """Relocate the element to a specific area."""
231 current = self.get("location")
232 if current and self.key in self.universe.contents[current].contents:
233 del universe.contents[current].contents[self.key]
234 if area in self.universe.contents:
235 self.set("location", area)
236 self.universe.contents[area].contents[self.key] = self
240 """Relocate the element to its default location."""
241 self.go_to(self.get("default_location"))
242 self.echo_to_location(
243 "You suddenly realize that " + self.get("name") + " is here."
246 def move_direction(self, direction):
247 """Relocate the element in a specified direction."""
248 motion = self.universe.contents["mudpy.movement.%s" % direction]
249 enter_term = motion.get("enter_term")
250 exit_term = motion.get("exit_term")
251 self.echo_to_location("%s exits %s." % (self.get("name"), exit_term))
252 self.send("You exit %s." % exit_term, add_prompt=False)
254 self.universe.contents[
255 self.get("location")].link_neighbor(direction)
257 self.echo_to_location("%s arrives from %s." % (
258 self.get("name"), enter_term))
260 def look_at(self, key):
261 """Show an element to another element."""
263 element = self.universe.contents[key]
265 name = element.get("name")
267 message += "$(cyn)" + name + "$(nrm)$(eol)"
268 description = element.get("description")
270 message += description + "$(eol)"
271 portal_list = list(element.portals().keys())
274 message += "$(cyn)[ Exits: " + ", ".join(
277 for element in self.universe.contents[
280 if element.get("is_actor") and element is not self:
281 message += "$(yel)" + element.get(
283 ) + " is here.$(nrm)$(eol)"
284 elif element is not self:
285 message += "$(grn)" + element.get(
291 """Map the portal directions for an area to neighbors."""
293 if re.match(r"""^area\.-?\d+,-?\d+,-?\d+$""", self.key):
294 coordinates = [(int(x))
295 for x in self.key.split(".")[-1].split(",")]
298 self.universe.contents["mudpy.movement.%s" % x].get("vector")
299 ) for x in self.universe.directions)
300 for portal in self.get("gridlinks"):
301 adjacent = map(lambda c, o: c + o,
302 coordinates, offsets[portal])
303 neighbor = "area." + ",".join(
304 [(str(x)) for x in adjacent]
306 if neighbor in self.universe.contents:
307 portals[portal] = neighbor
308 for facet in self.facets():
309 if facet.startswith("link_"):
310 neighbor = self.get(facet)
311 if neighbor in self.universe.contents:
312 portal = facet.split("_")[1]
313 portals[portal] = neighbor
316 def link_neighbor(self, direction):
317 """Return the element linked in a given direction."""
318 portals = self.portals()
319 if direction in portals:
320 return portals[direction]
322 def echo_to_location(self, message):
323 """Show a message to other elements in the current location."""
324 for element in self.universe.contents[
327 if element is not self:
328 element.send(message)
335 def __init__(self, filename="", load=False):
336 """Initialize the universe."""
339 self.directions = set()
343 self.reload_flag = False
344 self.setup_loglines = []
345 self.startdir = os.getcwd()
346 self.terminate_flag = False
350 possible_filenames = [
352 "/usr/local/mudpy/etc/mudpy.yaml",
353 "/usr/local/etc/mudpy.yaml",
354 "/etc/mudpy/mudpy.yaml",
357 for filename in possible_filenames:
358 if os.access(filename, os.R_OK):
360 if not os.path.isabs(filename):
361 filename = os.path.join(self.startdir, filename)
362 self.filename = filename
364 # make sure to preserve any accumulated log entries during load
365 self.setup_loglines += self.load()
368 """Load universe data from persistent storage."""
370 # while loading, it's safe to update elements from read-only files
373 # it's possible for this to enter before logging configuration is read
374 pending_loglines = []
376 # start populating the (re)files dict from the base config
378 mudpy.data.Data(self.filename, self)
380 # load default storage locations for groups
381 if hasattr(self, "contents") and "mudpy.filing" in self.contents:
382 self.origins.update(self.contents["mudpy.filing"].get(
385 # add some builtin groups we know we'll need
386 for group in ("account", "actor", "internal"):
387 self.add_group(group)
389 # make a list of inactive avatars
390 inactive_avatars = []
391 for account in self.groups.get("account", {}).values():
392 for avatar in account.get("avatars"):
394 inactive_avatars.append(self.contents[avatar])
396 pending_loglines.append((
397 'Missing avatar "%s", possible data corruption' %
399 for user in self.userlist:
400 if user.avatar in inactive_avatars:
401 inactive_avatars.remove(user.avatar)
403 # another pass to straighten out all the element contents
404 for element in self.contents.values():
405 element.update_location()
406 element.clean_contents()
408 # warn when debug mode has been engaged
409 if self.debug_mode():
410 pending_loglines.append((
411 "WARNING: Unsafe debugging mode is enabled!", 6))
413 # done loading, so disallow updating elements from read-only files
416 return pending_loglines
419 """Create a new, empty Universe (the Big Bang)."""
420 new_universe = Universe()
421 for attribute in vars(self).keys():
422 setattr(new_universe, attribute, getattr(self, attribute))
423 new_universe.reload_flag = False
428 """Save the universe to persistent storage."""
429 for key in self.files:
430 self.files[key].save()
432 def initialize_server_socket(self):
433 """Create and open the listening socket."""
435 # need to know the local address and port number for the listener
436 host = self.contents["mudpy.network"].get("host")
437 port = self.contents["mudpy.network"].get("port")
439 # if no host was specified, bind to the loopback address (preferring
447 # figure out if this is ipv4 or v6
448 family = socket.getaddrinfo(host, port)[0][0]
449 if family is socket.AF_INET6 and not socket.has_ipv6:
450 log("No support for IPv6 address %s (use IPv4 instead)." % host)
452 # create a new stream-type socket object
453 self.listening_socket = socket.socket(family, socket.SOCK_STREAM)
455 # set the socket options to allow existing open ones to be
456 # reused (fixes a bug where the server can't bind for a minute
457 # when restarting on linux systems)
458 self.listening_socket.setsockopt(
459 socket.SOL_SOCKET, socket.SO_REUSEADDR, 1
462 # bind the socket to to our desired server ipa and port
463 self.listening_socket.bind((host, port))
465 # disable blocking so we can proceed whether or not we can
467 self.listening_socket.setblocking(0)
469 # start listening on the socket
470 self.listening_socket.listen(1)
472 # note that we're now ready for user connections
473 log("Listening for Telnet connections on %s port %s" % (
477 """Convenience method to get the elapsed time counter."""
479 return self.groups["internal"]["counters"].get("elapsed", 0)
483 def set_time(self, elapsed):
484 """Convenience method to set the elapsed time counter."""
486 self.groups["internal"]["counters"].set("elapsed", elapsed)
488 # add an element for counters if it doesn't exist
489 Element("internal.counters", universe)
490 self.groups["internal"]["counters"].set("elapsed", elapsed)
492 def add_group(self, group, fallback=None):
493 """Set up group tracking/metadata."""
494 if group not in self.origins:
495 self.origins[group] = {}
497 fallback = mudpy.data.find_file(
498 ".".join((group, "yaml")), universe=self)
499 if "fallback" not in self.origins[group]:
500 self.origins[group]["fallback"] = fallback
501 flags = self.origins[group].get("flags", None)
502 if fallback not in self.files:
503 mudpy.data.Data(fallback, self, flags=flags)
505 def debug_mode(self):
506 """Boolean method to indicate whether unsafe debugging is enabled."""
507 return self.groups["mudpy"]["limit"].get("debug", False)
512 """This is a connected user."""
515 """Default values for the in-memory user variables."""
518 self.authenticated = False
522 self.connection = None
524 self.input_queue = []
525 self.last_address = ""
526 self.last_input = universe.get_time()
527 self.menu_choices = {}
528 self.menu_seen = False
529 self.negotiation_pause = 0
530 self.output_queue = []
531 self.partial_input = b""
532 self.password_tries = 0
534 self.state = "telopt_negotiation"
537 self.universe = universe
540 """Log, close the connection and remove."""
542 name = self.account.get("name", self)
545 log("Logging out %s" % name, 2)
546 self.deactivate_avatar()
547 self.connection.close()
550 def check_idle(self):
551 """Warn or disconnect idle users as appropriate."""
552 idletime = universe.get_time() - self.last_input
553 linkdead_dict = universe.contents[
554 "mudpy.timing.idle.disconnect"].facets()
555 if self.state in linkdead_dict:
556 linkdead_state = self.state
558 linkdead_state = "default"
559 if idletime > linkdead_dict[linkdead_state]:
561 "$(eol)$(red)You've done nothing for far too long... goodbye!"
566 logline = "Disconnecting "
567 if self.account and self.account.get("name"):
568 logline += self.account.get("name")
570 logline += "an unknown user"
571 logline += (" after idling too long in the " + self.state
574 self.state = "disconnecting"
575 self.menu_seen = False
576 idle_dict = universe.contents["mudpy.timing.idle.warn"].facets()
577 if self.state in idle_dict:
578 idle_state = self.state
580 idle_state = "default"
581 if idletime == idle_dict[idle_state]:
583 "$(eol)$(red)If you continue to be unproductive, "
584 + "you'll be shown the door...$(nrm)$(eol)"
588 """Save, load a new user and relocate the connection."""
590 # copy old attributes
591 attributes = self.__dict__
593 # get out of the list
596 # get rid of the old user object
599 # create a new user object
602 # set everything equivalent
603 new_user.__dict__ = attributes
605 # the avatar needs a new owner
607 new_user.account = universe.contents[new_user.account.key]
608 new_user.avatar = universe.contents[new_user.avatar.key]
609 new_user.avatar.owner = new_user
612 universe.userlist.append(new_user)
614 def replace_old_connections(self):
615 """Disconnect active users with the same name."""
617 # the default return value
620 # iterate over each user in the list
621 for old_user in universe.userlist:
623 # the name is the same but it's not us
626 ) and old_user.account and old_user.account.get(
628 ) == self.account.get(
630 ) and old_user is not self:
634 "User " + self.account.get(
636 ) + " reconnected--closing old connection to "
637 + old_user.address + ".",
641 "$(eol)$(red)New connection from " + self.address
642 + ". Terminating old connection...$(nrm)$(eol)",
647 # close the old connection
648 old_user.connection.close()
650 # replace the old connection with this one
652 "$(eol)$(red)Taking over old connection from "
653 + old_user.address + ".$(nrm)"
655 old_user.connection = self.connection
656 old_user.last_address = old_user.address
657 old_user.address = self.address
659 # take this one out of the list and delete
665 # true if an old connection was replaced, false if not
668 def authenticate(self):
669 """Flag the user as authenticated and disconnect duplicates."""
670 if self.state != "authenticated":
671 self.authenticated = True
672 log("User %s authenticated for account %s." % (
673 self, self.account.subkey), 2)
674 if ("mudpy.limit" in universe.contents and self.account.subkey in
675 universe.contents["mudpy.limit"].get("admins")):
676 self.account.set("administrator", True)
677 log("Account %s is an administrator." % (
678 self.account.subkey), 2)
681 """Send the user their current menu."""
682 if not self.menu_seen:
683 self.menu_choices = get_menu_choices(self)
685 get_menu(self.state, self.error, self.menu_choices),
689 self.menu_seen = True
691 self.adjust_echoing()
694 """"Generate and return an input prompt."""
696 # Start with the user's preference, if one was provided
697 prompt = self.account.get("prompt")
699 # If the user has not set a prompt, then immediately return the default
700 # provided for the current state
702 return get_menu_prompt(self.state)
704 # Allow including the World clock state
705 if "$_(time)" in prompt:
706 prompt = prompt.replace(
708 str(universe.get_time()))
710 # Append a single space for clear separation from user input
711 if prompt[-1] != " ":
712 prompt = "%s " % prompt
714 # Return the cooked prompt
717 def adjust_echoing(self):
718 """Adjust echoing to match state menu requirements."""
719 if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_ECHO,
721 if menu_echo_on(self.state):
722 mudpy.telnet.disable(self, mudpy.telnet.TELOPT_ECHO,
724 elif not menu_echo_on(self.state):
725 mudpy.telnet.enable(self, mudpy.telnet.TELOPT_ECHO,
729 """Remove a user from the list of connected users."""
730 log("Disconnecting account %s." % self, 0)
731 universe.userlist.remove(self)
741 add_terminator=False,
744 """Send arbitrary text to a connected user."""
746 # unless raw mode is on, clean it up all nice and pretty
749 # strip extra $(eol) off if present
750 while output.startswith("$(eol)"):
752 while output.endswith("$(eol)"):
754 extra_lines = output.find("$(eol)$(eol)$(eol)")
755 while extra_lines > -1:
756 output = output[:extra_lines] + output[extra_lines + 6:]
757 extra_lines = output.find("$(eol)$(eol)$(eol)")
759 # start with a newline, append the message, then end
760 # with the optional eol string passed to this function
761 # and the ansi escape to return to normal text
762 if not just_prompt and prepend_padding:
763 if (not self.output_queue or not
764 self.output_queue[-1].endswith(b"\r\n")):
765 output = "$(eol)" + output
766 elif not self.output_queue[-1].endswith(
768 ) and not self.output_queue[-1].endswith(
771 output = "$(eol)" + output
772 output += eol + chr(27) + "[0m"
774 # tack on a prompt if active
775 if self.state == "active":
779 output += self.prompt()
780 mode = self.avatar.get("mode")
782 output += "(" + mode + ") "
784 # find and replace macros in the output
785 output = replace_macros(self, output)
787 # wrap the text at the client's width (min 40, 0 disables)
789 if self.columns < 40:
793 output = wrap_ansi_text(output, wrap)
795 # if supported by the client, encode it utf-8
796 if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_BINARY,
798 encoded_output = output.encode("utf-8")
800 # otherwise just send ascii
802 encoded_output = output.encode("ascii", "replace")
804 # end with a terminator if requested
805 if add_prompt or add_terminator:
806 if mudpy.telnet.is_enabled(
807 self, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US):
808 encoded_output += mudpy.telnet.telnet_proto(
809 mudpy.telnet.IAC, mudpy.telnet.EOR)
810 elif not mudpy.telnet.is_enabled(
811 self, mudpy.telnet.TELOPT_SGA, mudpy.telnet.US):
812 encoded_output += mudpy.telnet.telnet_proto(
813 mudpy.telnet.IAC, mudpy.telnet.GA)
815 # and tack it onto the queue
816 self.output_queue.append(encoded_output)
818 # if this is urgent, flush all pending output
822 # just dump raw bytes as requested
824 self.output_queue.append(output)
828 """All the things to do to the user per increment."""
830 # if the world is terminating, disconnect
831 if universe.terminate_flag:
832 self.state = "disconnecting"
833 self.menu_seen = False
835 # check for an idle connection and act appropriately
839 # ask the client for their current terminal type (RFC 1091); it's None
840 # if it's not been initialized, the empty string if it has but the
841 # output was indeterminate, "UNKNOWN" if the client specified it has no
842 # terminal types to supply
843 if self.ttype is None:
844 mudpy.telnet.request_ttype(self)
846 # if output is paused, decrement the counter
847 if self.state == "telopt_negotiation":
848 if self.negotiation_pause:
849 self.negotiation_pause -= 1
851 self.state = "entering_account_name"
853 # show the user a menu as needed
854 elif not self.state == "active":
857 # flush any pending output in the queue
860 # disconnect users with the appropriate state
861 if self.state == "disconnecting":
864 # check for input and add it to the queue
867 # there is input waiting in the queue
869 handle_user_input(self)
872 """Try to send the last item in the queue and remove it."""
873 if self.output_queue:
875 self.connection.send(self.output_queue[0])
876 except (BrokenPipeError, ConnectionResetError):
877 if self.account and self.account.get("name"):
878 account = self.account.get("name")
880 account = "an unknown user"
881 self.state = "disconnecting"
882 log("Disconnected while sending to %s." % account, 7)
883 del self.output_queue[0]
885 def enqueue_input(self):
886 """Process and enqueue any new input."""
888 # check for some input
890 raw_input = self.connection.recv(1024)
897 # tack this on to any previous partial
898 self.partial_input += raw_input
900 # reply to and remove any IAC negotiation codes
901 mudpy.telnet.negotiate_telnet_options(self)
903 # separate multiple input lines
904 new_input_lines = self.partial_input.split(b"\r\0")
905 if len(new_input_lines) == 1:
906 new_input_lines = new_input_lines[0].split(b"\r\n")
908 # if input doesn't end in a newline, replace the
909 # held partial input with the last line of it
911 self.partial_input.endswith(b"\r\0") or
912 self.partial_input.endswith(b"\r\n")):
913 self.partial_input = new_input_lines.pop()
915 # otherwise, chop off the extra null input and reset
916 # the held partial input
918 new_input_lines.pop()
919 self.partial_input = b""
921 # iterate over the remaining lines
922 for line in new_input_lines:
924 # strip off extra whitespace
927 # log non-printable characters remaining
928 if not mudpy.telnet.is_enabled(
929 self, mudpy.telnet.TELOPT_BINARY, mudpy.telnet.HIM):
930 asciiline = bytes([x for x in line if 32 <= x <= 126])
931 if line != asciiline:
932 logline = "Non-ASCII characters from "
933 if self.account and self.account.get("name"):
934 logline += self.account.get("name") + ": "
936 logline += "unknown user: "
937 logline += repr(line)
942 line = line.decode("utf-8")
943 except UnicodeDecodeError:
944 logline = "Non-UTF-8 sequence from "
945 if self.account and self.account.get("name"):
946 logline += self.account.get("name") + ": "
948 logline += "unknown user: "
949 logline += repr(line)
953 line = unicodedata.normalize("NFKC", line)
955 # put on the end of the queue
956 self.input_queue.append(line)
958 def new_avatar(self):
959 """Instantiate a new, unconfigured avatar for this user."""
961 while ("avatar_%s_%s" % (self.account.get("name"), counter)
962 in universe.groups.get("actor", {}).keys()):
964 self.avatar = Element(
965 "actor.avatar_%s_%s" % (self.account.get("name"), counter),
967 self.avatar.append("inherit", "archetype.avatar")
968 self.account.append("avatars", self.avatar.key)
969 log("Created new avatar %s for user %s." % (
970 self.avatar.key, self.account.get("name")), 0)
972 def delete_avatar(self, avatar):
973 """Remove an avatar from the world and from the user's list."""
974 if self.avatar is universe.contents[avatar]:
976 log("Deleting avatar %s for user %s." % (
977 avatar, self.account.get("name")), 0)
978 universe.contents[avatar].destroy()
979 avatars = self.account.get("avatars")
980 avatars.remove(avatar)
981 self.account.set("avatars", avatars)
983 def activate_avatar_by_index(self, index):
984 """Enter the world with a particular indexed avatar."""
985 self.avatar = universe.contents[
986 self.account.get("avatars")[index]]
987 self.avatar.owner = self
988 self.state = "active"
989 log("Activated avatar %s (%s)." % (
990 self.avatar.get("name"), self.avatar.key), 0)
991 self.avatar.go_home()
993 def deactivate_avatar(self):
994 """Have the active avatar leave the world."""
996 log("Deactivating avatar %s (%s) for user %s." % (
997 self.avatar.get("name"), self.avatar.key,
998 self.account.get("name")), 0)
999 current = self.avatar.get("location")
1001 self.avatar.set("default_location", current)
1002 self.avatar.echo_to_location(
1003 "You suddenly wonder where " + self.avatar.get(
1007 del universe.contents[current].contents[self.avatar.key]
1008 self.avatar.remove_facet("location")
1009 self.avatar.owner = None
1013 """Destroy the user and associated avatars."""
1014 for avatar in self.account.get("avatars"):
1015 self.delete_avatar(avatar)
1016 log("Destroying account %s for user %s." % (
1017 self.account.get("name"), self), 0)
1018 self.account.destroy()
1020 def list_avatar_names(self):
1021 """List names of assigned avatars."""
1023 for avatar in self.account.get("avatars"):
1025 avatars.append(universe.contents[avatar].get("name"))
1027 log('Missing avatar "%s", possible data corruption.' %
1032 """Boolean check whether user's account is an admin."""
1033 return self.account.get("administrator", False)
1036 def broadcast(message, add_prompt=True):
1037 """Send a message to all connected users."""
1038 for each_user in universe.userlist:
1039 each_user.send("$(eol)" + message, add_prompt=add_prompt)
1042 def log(message, level=0):
1043 """Log a message."""
1045 # a couple references we need
1046 if "mudpy.log" in universe.contents:
1047 file_name = universe.contents["mudpy.log"].get("file", "")
1048 max_log_lines = universe.contents["mudpy.log"].get("lines", 0)
1049 syslog_name = universe.contents["mudpy.log"].get("syslog", "")
1054 timestamp = datetime.datetime.now().isoformat(' ')
1056 # turn the message into a list of nonempty lines
1057 lines = [x for x in [(x.rstrip()) for x in message.split("\n")] if x != ""]
1059 # send the timestamp and line to a file
1061 if not os.path.isabs(file_name):
1062 file_name = os.path.join(universe.startdir, file_name)
1063 os.makedirs(os.path.dirname(file_name), exist_ok=True)
1064 file_descriptor = codecs.open(file_name, "a", "utf-8")
1066 file_descriptor.write(timestamp + " " + line + "\n")
1067 file_descriptor.flush()
1068 file_descriptor.close()
1070 # send the timestamp and line to standard output
1071 if ("mudpy.log" in universe.contents and
1072 universe.contents["mudpy.log"].get("stdout")):
1074 print(timestamp + " " + line)
1076 # send the line to the system log
1079 syslog_name.encode("utf-8"),
1081 syslog.LOG_INFO | syslog.LOG_DAEMON
1087 # display to connected administrators
1088 for user in universe.userlist:
1090 user.state == "active"
1092 and user.account.get("loglevel", 0) <= level):
1093 # iterate over every line in the message
1097 "$(bld)$(red)" + timestamp + " "
1098 + line.replace("$(", "$_(") + "$(nrm)$(eol)")
1099 user.send(full_message, flush=True)
1101 # add to the recent log list
1103 while 0 < len(universe.loglines) >= max_log_lines:
1104 del universe.loglines[0]
1105 universe.loglines.append((timestamp + " " + line, level))
1108 def get_loglines(level, start, stop):
1109 """Return a specific range of loglines filtered by level."""
1111 # filter the log lines
1112 loglines = [x for x in universe.loglines if x[1] >= level]
1114 # we need these in several places
1115 total_count = str(len(universe.loglines))
1116 filtered_count = len(loglines)
1118 # don't proceed if there are no lines
1121 # can't start before the beginning or at the end
1122 if start > filtered_count:
1123 start = filtered_count
1127 # can't stop before we start
1135 "There are %s log lines in memory and %s at or above level %s. "
1136 "The matching lines from %s to %s are:$(eol)$(eol)" %
1137 (total_count, filtered_count, level, stop, start))
1139 # add the text from the selected lines
1141 range_lines = loglines[-start:-(stop - 1)]
1143 range_lines = loglines[-start:]
1144 for line in range_lines:
1145 message += " (%s) %s$(eol)" % (
1146 line[1], line[0].replace("$(", "$_("))
1148 # there were no lines
1150 message = "None of the %s lines in memory matches your request." % (
1157 def glyph_columns(character):
1158 """Convenience function to return the column width of a glyph."""
1159 if unicodedata.east_asian_width(character) in "FW":
1165 def wrap_ansi_text(text, width):
1166 """Wrap text with arbitrary width while ignoring ANSI colors."""
1168 # the current position in the entire text string, including all
1169 # characters, printable or otherwise
1172 # the current text position relative to the beginning of the line,
1173 # ignoring color escape sequences
1176 # the absolute and relative positions of the most recent whitespace
1178 last_abs_whitespace = 0
1179 last_rel_whitespace = 0
1181 # whether the current character is part of a color escape sequence
1184 # normalize any potentially composited unicode before we count it
1185 text = unicodedata.normalize("NFKC", text)
1187 # iterate over each character from the beginning of the text
1188 for each_character in text:
1190 # the current character is the escape character
1191 if each_character == "\x1b" and not escape:
1195 # the current character is within an escape sequence
1198 if each_character == "m":
1199 # the current character is m, which terminates the
1203 # the current character is a space
1204 elif each_character == " ":
1205 last_abs_whitespace = abs_pos
1206 last_rel_whitespace = rel_pos
1208 # the current character is a newline, so reset the relative
1209 # position too (start a new line)
1210 elif each_character == "\n":
1212 last_abs_whitespace = abs_pos
1213 last_rel_whitespace = rel_pos
1215 # the current character meets the requested maximum line width, so we
1216 # need to wrap unless the current word is wider than the terminal (in
1217 # which case we let it do the wrapping instead)
1218 if last_rel_whitespace != 0 and (rel_pos > width or (
1219 rel_pos > width - 1 and glyph_columns(each_character) == 2)):
1221 # insert an eol in place of the last space
1222 text = (text[:last_abs_whitespace] + "\r\n" +
1223 text[last_abs_whitespace + 1:])
1225 # increase the absolute position because an eol is two
1226 # characters but the space it replaced was only one
1229 # now we're at the beginning of a new line, plus the
1230 # number of characters wrapped from the previous line
1231 rel_pos -= last_rel_whitespace
1232 last_rel_whitespace = 0
1234 # as long as the character is not a carriage return and the
1235 # other above conditions haven't been met, count it as a
1236 # printable character
1237 elif each_character != "\r":
1238 rel_pos += glyph_columns(each_character)
1239 if each_character in (" ", "\n"):
1240 last_abs_whitespace = abs_pos
1241 last_rel_whitespace = rel_pos
1243 # increase the absolute position for every character
1246 # return the newly-wrapped text
1250 def weighted_choice(data):
1251 """Takes a dict weighted by value and returns a random key."""
1253 # this will hold our expanded list of keys from the data
1256 # create the expanded list of keys
1257 for key in data.keys():
1258 for _count in range(data[key]):
1259 expanded.append(key)
1261 # return one at random
1262 # Allow the random.randrange() call in bandit since it's not used for
1263 # security/cryptographic purposes
1264 return random.choice(expanded) # nosec
1268 """Returns a random character name."""
1270 # the vowels and consonants needed to create romaji syllables
1299 # this dict will hold our weighted list of syllables
1302 # generate the list with an even weighting
1303 for consonant in consonants:
1304 for vowel in vowels:
1305 syllables[consonant + vowel] = 1
1307 # we'll build the name into this string
1310 # create a name of random length from the syllables
1311 # Allow the random.randrange() call in bandit since it's not used for
1312 # security/cryptographic purposes
1313 for _syllable in range(random.randrange(2, 6)): # nosec
1314 name += weighted_choice(syllables)
1316 # strip any leading quotemark, capitalize and return the name
1317 return name.strip("'").capitalize()
1320 def replace_macros(user, text, is_input=False):
1321 """Replaces macros in text output."""
1323 # third person pronouns
1325 "female": {"obj": "her", "pos": "hers", "sub": "she"},
1326 "male": {"obj": "him", "pos": "his", "sub": "he"},
1327 "neuter": {"obj": "it", "pos": "its", "sub": "it"}
1330 # a dict of replacement macros
1333 "bld": chr(27) + "[1m",
1334 "nrm": chr(27) + "[0m",
1335 "blk": chr(27) + "[30m",
1336 "blu": chr(27) + "[34m",
1337 "cyn": chr(27) + "[36m",
1338 "grn": chr(27) + "[32m",
1339 "mgt": chr(27) + "[35m",
1340 "red": chr(27) + "[31m",
1341 "yel": chr(27) + "[33m",
1344 # add dynamic macros where possible
1346 account_name = user.account.get("name")
1348 macros["account"] = account_name
1350 avatar_gender = user.avatar.get("gender")
1352 macros["tpop"] = pronouns[avatar_gender]["obj"]
1353 macros["tppp"] = pronouns[avatar_gender]["pos"]
1354 macros["tpsp"] = pronouns[avatar_gender]["sub"]
1359 # find and replace per the macros dict
1360 macro_start = text.find("$(")
1361 if macro_start == -1:
1363 macro_end = text.find(")", macro_start) + 1
1364 macro = text[macro_start + 2:macro_end - 1]
1365 if macro in macros.keys():
1366 replacement = macros[macro]
1368 # this is how we handle local file inclusion (dangerous!)
1369 elif macro.startswith("inc:"):
1370 incfile = mudpy.data.find_file(macro[4:], universe=universe)
1371 if os.path.exists(incfile):
1372 incfd = codecs.open(incfile, "r", "utf-8")
1375 if line.endswith("\n") and not line.endswith("\r\n"):
1376 line = line.replace("\n", "\r\n")
1378 # lose the trailing eol
1379 replacement = replacement[:-2]
1382 log("Couldn't read included " + incfile + " file.", 7)
1384 # if we get here, log and replace it with null
1388 log("Unexpected replacement macro " +
1389 macro + " encountered.", 6)
1391 # and now we act on the replacement
1392 text = text.replace("$(" + macro + ")", replacement)
1394 # replace the look-like-a-macro sequence
1395 text = text.replace("$_(", "$(")
1400 def escape_macros(value):
1401 """Escapes replacement macros in text."""
1402 if type(value) is str:
1403 return value.replace("$(", "$_(")
1408 def first_word(text, separator=" "):
1409 """Returns a tuple of the first word and the rest."""
1411 if text.find(separator) > 0:
1412 return text.split(separator, 1)
1420 """The things which should happen on each pulse, aside from reloads."""
1422 # increase the elapsed increment counter
1423 universe.set_time(universe.get_time() + 1)
1425 # update the log every now and then
1426 if not universe.groups["internal"]["counters"].get("mark"):
1427 log(str(len(universe.userlist)) + " connection(s)")
1428 universe.groups["internal"]["counters"].set(
1429 "mark", universe.contents["mudpy.timing"].get("status")
1432 universe.groups["internal"]["counters"].set(
1433 "mark", universe.groups["internal"]["counters"].get(
1438 # periodically save everything
1439 if not universe.groups["internal"]["counters"].get("save"):
1441 universe.groups["internal"]["counters"].set(
1442 "save", universe.contents["mudpy.timing"].get("save")
1445 universe.groups["internal"]["counters"].set(
1446 "save", universe.groups["internal"]["counters"].get(
1451 # open the listening socket if it hasn't been already
1452 if not hasattr(universe, "listening_socket"):
1453 universe.initialize_server_socket()
1455 # assign a user if a new connection is waiting
1456 user = check_for_connection(universe.listening_socket)
1458 universe.userlist.append(user)
1460 # iterate over the connected users
1461 for user in universe.userlist:
1464 # pause for a configurable amount of time (decimal seconds)
1465 time.sleep(universe.contents["mudpy.timing"].get("increment"))
1469 """Reload all relevant objects."""
1471 old_userlist = universe.userlist[:]
1472 old_loglines = universe.loglines[:]
1473 for element in list(universe.contents.values()):
1475 pending_loglines = universe.load()
1476 new_loglines = universe.loglines[:]
1477 universe.loglines = old_loglines + new_loglines + pending_loglines
1478 for user in old_userlist:
1482 def check_for_connection(listening_socket):
1483 """Check for a waiting connection and return a new user object."""
1485 # try to accept a new connection
1487 connection, address = listening_socket.accept()
1488 except BlockingIOError:
1491 # note that we got one
1492 log("New connection from %s." % address[0], 2)
1494 # disable blocking so we can proceed whether or not we can send/receive
1495 connection.setblocking(0)
1497 # create a new user object
1499 log("Instantiated %s for %s." % (user, address[0]), 0)
1501 # associate this connection with it
1502 user.connection = connection
1504 # set the user's ipa from the connection's ipa
1505 user.address = address[0]
1507 # let the client know we WILL EOR (RFC 885)
1508 mudpy.telnet.enable(user, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US)
1509 user.negotiation_pause = 2
1511 # return the new user object
1515 def find_command(command_name):
1516 """Try to find a command by name or abbreviation."""
1518 # lowercase the command
1519 command_name = command_name.lower()
1522 if command_name in universe.groups["command"]:
1523 # the command matches a command word for which we have data
1524 command = universe.groups["command"][command_name]
1526 for candidate in sorted(universe.groups["command"]):
1527 if candidate.startswith(command_name) and not universe.groups[
1528 "command"][candidate].is_restricted():
1529 # the command matches the start of a command word and is not
1530 # restricted to administrators
1531 command = universe.groups["command"][candidate]
1536 def get_menu(state, error=None, choices=None):
1537 """Show the correct menu text to a user."""
1539 # make sure we don't reuse a mutable sequence by default
1543 # get the description or error text
1544 message = get_menu_description(state, error)
1546 # get menu choices for the current state
1547 message += get_formatted_menu_choices(state, choices)
1549 # try to get a prompt, if it was defined
1550 message += get_menu_prompt(state)
1552 # throw in the default choice, if it exists
1553 message += get_formatted_default_menu_choice(state)
1555 # display a message indicating if echo is off
1556 message += get_echo_message(state)
1558 # return the assembly of various strings defined above
1562 def menu_echo_on(state):
1563 """True if echo is on, false if it is off."""
1564 return universe.groups["menu"][state].get("echo", True)
1567 def get_echo_message(state):
1568 """Return a message indicating that echo is off."""
1569 if menu_echo_on(state):
1572 return "(won't echo) "
1575 def get_default_menu_choice(state):
1576 """Return the default choice for a menu."""
1577 return universe.groups["menu"][state].get("default")
1580 def get_formatted_default_menu_choice(state):
1581 """Default menu choice foratted for inclusion in a prompt string."""
1582 default_choice = get_default_menu_choice(state)
1584 return "[$(red)" + default_choice + "$(nrm)] "
1589 def get_menu_description(state, error):
1590 """Get the description or error text."""
1592 # an error condition was raised by the handler
1595 # try to get an error message matching the condition
1597 description = universe.groups[
1598 "menu"][state].get("error_" + error)
1600 description = "That is not a valid choice..."
1601 description = "$(red)" + description + "$(nrm)"
1603 # there was no error condition
1606 # try to get a menu description for the current state
1607 description = universe.groups["menu"][state].get("description")
1609 # return the description or error message
1611 description += "$(eol)$(eol)"
1615 def get_menu_prompt(state):
1616 """Try to get a prompt, if it was defined."""
1617 prompt = universe.groups["menu"][state].get("prompt")
1623 def get_menu_choices(user):
1624 """Return a dict of choice:meaning."""
1625 state = universe.groups["menu"][user.state]
1626 create_choices = state.get("create")
1628 choices = call_hook_function(create_choices, (user,))
1634 for facet in state.facets():
1635 if facet.startswith("demand_") and not call_hook_function(
1636 universe.groups["menu"][user.state].get(facet), (user,)):
1637 ignores.append(facet.split("_", 2)[1])
1638 elif facet.startswith("create_"):
1639 creates[facet] = facet.split("_", 2)[1]
1640 elif facet.startswith("choice_"):
1641 options[facet] = facet.split("_", 2)[1]
1642 for facet in creates.keys():
1643 if not creates[facet] in ignores:
1644 choices[creates[facet]] = call_hook_function(
1645 state.get(facet), (user,))
1646 for facet in options.keys():
1647 if not options[facet] in ignores:
1648 choices[options[facet]] = state.get(facet)
1652 def get_formatted_menu_choices(state, choices):
1653 """Returns a formatted string of menu choices."""
1655 choice_keys = list(choices.keys())
1657 for choice in choice_keys:
1658 choice_output += " [$(red)" + choice + "$(nrm)] " + choices[
1662 choice_output += "$(eol)"
1663 return choice_output
1666 def get_menu_branches(state):
1667 """Return a dict of choice:branch."""
1669 for facet in universe.groups["menu"][state].facets():
1670 if facet.startswith("branch_"):
1672 facet.split("_", 2)[1]
1673 ] = universe.groups["menu"][state].get(facet)
1677 def get_default_branch(state):
1678 """Return the default branch."""
1679 return universe.groups["menu"][state].get("branch")
1682 def get_choice_branch(user):
1683 """Returns the new state matching the given choice."""
1684 branches = get_menu_branches(user.state)
1685 if user.choice in branches.keys():
1686 return branches[user.choice]
1687 elif user.choice in user.menu_choices.keys():
1688 return get_default_branch(user.state)
1693 def get_menu_actions(state):
1694 """Return a dict of choice:branch."""
1696 for facet in universe.groups["menu"][state].facets():
1697 if facet.startswith("action_"):
1699 facet.split("_", 2)[1]
1700 ] = universe.groups["menu"][state].get(facet)
1704 def get_default_action(state):
1705 """Return the default action."""
1706 return universe.groups["menu"][state].get("action")
1709 def get_choice_action(user):
1710 """Run any indicated script for the given choice."""
1711 actions = get_menu_actions(user.state)
1712 if user.choice in actions.keys():
1713 return actions[user.choice]
1714 elif user.choice in user.menu_choices.keys():
1715 return get_default_action(user.state)
1720 def call_hook_function(fname, arglist):
1721 """Safely execute named function with supplied arguments, return result."""
1723 # all functions relative to mudpy package
1726 for component in fname.split("."):
1728 function = getattr(function, component)
1729 except AttributeError:
1730 log('Could not find mudpy.%s() for arguments "%s"'
1731 % (fname, arglist), 7)
1736 return function(*arglist)
1738 log('Calling mudpy.%s(%s) raised an exception...\n%s'
1739 % (fname, (*arglist,), traceback.format_exc()), 7)
1742 def handle_user_input(user):
1743 """The main handler, branches to a state-specific handler."""
1745 # if the user's client echo is off, send a blank line for aesthetics
1746 if mudpy.telnet.is_enabled(user, mudpy.telnet.TELOPT_ECHO,
1748 user.send("", add_prompt=False, prepend_padding=False)
1750 # check to make sure the state is expected, then call that handler
1752 globals()["handler_" + user.state](user)
1754 generic_menu_handler(user)
1756 # since we got input, flag that the menu/prompt needs to be redisplayed
1757 user.menu_seen = False
1759 # update the last_input timestamp while we're at it
1760 user.last_input = universe.get_time()
1763 def generic_menu_handler(user):
1764 """A generic menu choice handler."""
1766 # get a lower-case representation of the next line of input
1767 if user.input_queue:
1768 user.choice = user.input_queue.pop(0)
1770 user.choice = user.choice.lower()
1774 user.choice = get_default_menu_choice(user.state)
1775 if user.choice in user.menu_choices:
1776 action = get_choice_action(user)
1778 call_hook_function(action, (user,))
1779 new_state = get_choice_branch(user)
1781 user.state = new_state
1783 user.error = "default"
1786 def handler_entering_account_name(user):
1787 """Handle the login account name."""
1789 # get the next waiting line of input
1790 input_data = user.input_queue.pop(0)
1792 # did the user enter anything?
1795 # keep only the first word and convert to lower-case
1796 name = input_data.lower()
1798 # fail if there are non-alphanumeric characters
1799 if name != "".join(filter(
1800 lambda x: x >= "0" and x <= "9" or x >= "a" and x <= "z",
1802 user.error = "bad_name"
1804 # if that account exists, time to request a password
1805 elif name in universe.groups.get("account", {}):
1806 user.account = universe.groups["account"][name]
1807 user.state = "checking_password"
1809 # otherwise, this could be a brand new user
1811 user.account = Element("account.%s" % name, universe)
1812 user.account.set("name", name)
1813 log("New user: " + name, 2)
1814 user.state = "checking_new_account_name"
1816 # if the user entered nothing for a name, then buhbye
1818 user.state = "disconnecting"
1821 def handler_checking_password(user):
1822 """Handle the login account password."""
1824 # get the next waiting line of input
1825 input_data = user.input_queue.pop(0)
1827 if "mudpy.limit" in universe.contents:
1828 max_password_tries = universe.contents["mudpy.limit"].get(
1829 "password_tries", 3)
1831 max_password_tries = 3
1833 # does the hashed input equal the stored hash?
1834 if mudpy.password.verify(input_data, user.account.get("passhash")):
1836 # if so, set the username and load from cold storage
1837 if not user.replace_old_connections():
1839 user.state = "main_utility"
1841 # if at first your hashes don't match, try, try again
1842 elif user.password_tries < max_password_tries - 1:
1843 user.password_tries += 1
1844 user.error = "incorrect"
1846 # we've exceeded the maximum number of password failures, so disconnect
1849 "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1851 user.state = "disconnecting"
1854 def handler_entering_new_password(user):
1855 """Handle a new password entry."""
1857 # get the next waiting line of input
1858 input_data = user.input_queue.pop(0)
1860 if "mudpy.limit" in universe.contents:
1861 max_password_tries = universe.contents["mudpy.limit"].get(
1862 "password_tries", 3)
1864 max_password_tries = 3
1866 # make sure the password is strong--at least one upper, one lower and
1867 # one digit, seven or more characters in length
1868 if len(input_data) > 6 and len(
1869 list(filter(lambda x: x >= "0" and x <= "9", input_data))
1871 list(filter(lambda x: x >= "A" and x <= "Z", input_data))
1873 list(filter(lambda x: x >= "a" and x <= "z", input_data))
1876 # hash and store it, then move on to verification
1877 user.account.set("passhash", mudpy.password.create(input_data))
1878 user.state = "verifying_new_password"
1880 # the password was weak, try again if you haven't tried too many times
1881 elif user.password_tries < max_password_tries - 1:
1882 user.password_tries += 1
1885 # too many tries, so adios
1888 "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1890 user.account.destroy()
1891 user.state = "disconnecting"
1894 def handler_verifying_new_password(user):
1895 """Handle the re-entered new password for verification."""
1897 # get the next waiting line of input
1898 input_data = user.input_queue.pop(0)
1900 if "mudpy.limit" in universe.contents:
1901 max_password_tries = universe.contents["mudpy.limit"].get(
1902 "password_tries", 3)
1904 max_password_tries = 3
1906 # hash the input and match it to storage
1907 if mudpy.password.verify(input_data, user.account.get("passhash")):
1910 # the hashes matched, so go active
1911 if not user.replace_old_connections():
1912 user.state = "main_utility"
1914 # go back to entering the new password as long as you haven't tried
1916 elif user.password_tries < max_password_tries - 1:
1917 user.password_tries += 1
1918 user.error = "differs"
1919 user.state = "entering_new_password"
1921 # otherwise, sayonara
1924 "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1926 user.account.destroy()
1927 user.state = "disconnecting"
1930 def handler_active(user):
1931 """Handle input for active users."""
1933 # get the next waiting line of input
1934 input_data = user.input_queue.pop(0)
1939 # split out the command and parameters
1941 mode = actor.get("mode")
1942 if mode and input_data.startswith("!"):
1943 command_name, parameters = first_word(input_data[1:])
1944 elif mode == "chat":
1945 command_name = "say"
1946 parameters = input_data
1948 command_name, parameters = first_word(input_data)
1950 # expand to an actual command
1951 command = find_command(command_name)
1953 # if it's allowed, do it
1955 if actor.can_run(command):
1956 action_fname = command.get("action", command.key)
1958 result = call_hook_function(action_fname, (actor, parameters))
1960 # if the command was not run, give an error
1962 mudpy.command.error(actor, input_data)
1964 # if no input, just idle back with a prompt
1966 user.send("", just_prompt=True)
1969 def daemonize(universe):
1970 """Fork and disassociate from everything."""
1972 # only if this is what we're configured to do
1973 if "mudpy.process" in universe.contents and universe.contents[
1974 "mudpy.process"].get("daemon"):
1976 # log before we start forking around, so the terminal gets the message
1977 log("Disassociating from the controlling terminal.")
1979 # fork off and die, so we free up the controlling terminal
1983 # switch to a new process group
1986 # fork some more, this time to free us from the old process group
1990 # reset the working directory so we don't needlessly tie up mounts
1993 # clear the file creation mask so we can bend it to our will later
1996 # redirect stdin/stdout/stderr and close off their former descriptors
1997 for stdpipe in range(3):
1999 sys.stdin = codecs.open("/dev/null", "r", "utf-8")
2000 sys.__stdin__ = codecs.open("/dev/null", "r", "utf-8")
2001 sys.stdout = codecs.open("/dev/null", "w", "utf-8")
2002 sys.stderr = codecs.open("/dev/null", "w", "utf-8")
2003 sys.__stdout__ = codecs.open("/dev/null", "w", "utf-8")
2004 sys.__stderr__ = codecs.open("/dev/null", "w", "utf-8")
2007 def create_pidfile(universe):
2008 """Write a file containing the current process ID."""
2009 pid = str(os.getpid())
2010 log("Process ID: " + pid)
2011 if "mudpy.process" in universe.contents:
2012 file_name = universe.contents["mudpy.process"].get("pidfile", "")
2016 if not os.path.isabs(file_name):
2017 file_name = os.path.join(universe.startdir, file_name)
2018 os.makedirs(os.path.dirname(file_name), exist_ok=True)
2019 file_descriptor = codecs.open(file_name, "w", "utf-8")
2020 file_descriptor.write(pid + "\n")
2021 file_descriptor.flush()
2022 file_descriptor.close()
2025 def remove_pidfile(universe):
2026 """Remove the file containing the current process ID."""
2027 if "mudpy.process" in universe.contents:
2028 file_name = universe.contents["mudpy.process"].get("pidfile", "")
2032 if not os.path.isabs(file_name):
2033 file_name = os.path.join(universe.startdir, file_name)
2034 if os.access(file_name, os.W_OK):
2035 os.remove(file_name)
2038 def excepthook(excepttype, value, tracebackdata):
2039 """Handle uncaught exceptions."""
2041 # assemble the list of errors into a single string
2043 traceback.format_exception(excepttype, value, tracebackdata)
2046 # try to log it, if possible
2049 except Exception as e:
2050 # try to write it to stderr, if possible
2051 sys.stderr.write(message + "\nException while logging...\n%s" % e)
2054 def sighook(what, where):
2055 """Handle external signals."""
2058 message = "Caught signal: "
2060 # for a hangup signal
2061 if what == signal.SIGHUP:
2062 message += "hangup (reloading)"
2063 universe.reload_flag = True
2065 # for a terminate signal
2066 elif what == signal.SIGTERM:
2067 message += "terminate (halting)"
2068 universe.terminate_flag = True
2070 # catchall for unexpected signals
2072 message += str(what) + " (unhandled)"
2078 def override_excepthook():
2079 """Redefine sys.excepthook with our own."""
2080 sys.excepthook = excepthook
2083 def assign_sighook():
2084 """Assign a customized handler for some signals."""
2085 signal.signal(signal.SIGHUP, sighook)
2086 signal.signal(signal.SIGTERM, sighook)
2090 """This contains functions to be performed when starting the engine."""
2092 # see if a configuration file was specified
2093 if len(sys.argv) > 1:
2094 conffile = sys.argv[1]
2100 universe = Universe(conffile, True)
2102 # report any loglines which accumulated during setup
2103 for logline in universe.setup_loglines:
2105 universe.setup_loglines = []
2107 # fork and disassociate
2110 # override the default exception handler so we get logging first thing
2111 override_excepthook()
2113 # set up custom signal handlers
2117 create_pidfile(universe)
2119 # load and store diagnostic info
2120 universe.versions = mudpy.version.Versions("mudpy")
2122 # log startup diagnostic messages
2123 log("On %s at %s" % (universe.versions.python_version, sys.executable), 1)
2124 log("Import path: %s" % ", ".join(sys.path), 1)
2125 log("Installed dependencies: %s" % universe.versions.dependencies_text, 1)
2126 log("Other python packages: %s" % universe.versions.environment_text, 1)
2127 log("Running version: %s" % universe.versions.version, 1)
2128 log("Initial directory: %s" % universe.startdir, 1)
2129 log("Command line: %s" % " ".join(sys.argv), 1)
2131 # pass the initialized universe back
2136 """These are functions performed when shutting down the engine."""
2138 # the loop has terminated, so save persistent data
2141 # log a final message
2142 log("Shutting down now.")
2144 # get rid of the pidfile
2145 remove_pidfile(universe)