1 """Miscellaneous functions for the mudpy engine."""
3 # Copyright (c) 2004-2020 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 # done loading, so disallow updating elements from read-only files
411 return pending_loglines
414 """Create a new, empty Universe (the Big Bang)."""
415 new_universe = Universe()
416 for attribute in vars(self).keys():
417 setattr(new_universe, attribute, getattr(self, attribute))
418 new_universe.reload_flag = False
423 """Save the universe to persistent storage."""
424 for key in self.files:
425 self.files[key].save()
427 def initialize_server_socket(self):
428 """Create and open the listening socket."""
430 # need to know the local address and port number for the listener
431 host = self.contents["mudpy.network"].get("host")
432 port = self.contents["mudpy.network"].get("port")
434 # if no host was specified, bind to all local addresses (preferring
442 # figure out if this is ipv4 or v6
443 family = socket.getaddrinfo(host, port)[0][0]
444 if family is socket.AF_INET6 and not socket.has_ipv6:
445 log("No support for IPv6 address %s (use IPv4 instead)." % host)
447 # create a new stream-type socket object
448 self.listening_socket = socket.socket(family, socket.SOCK_STREAM)
450 # set the socket options to allow existing open ones to be
451 # reused (fixes a bug where the server can't bind for a minute
452 # when restarting on linux systems)
453 self.listening_socket.setsockopt(
454 socket.SOL_SOCKET, socket.SO_REUSEADDR, 1
457 # bind the socket to to our desired server ipa and port
458 self.listening_socket.bind((host, port))
460 # disable blocking so we can proceed whether or not we can
462 self.listening_socket.setblocking(0)
464 # start listening on the socket
465 self.listening_socket.listen(1)
467 # note that we're now ready for user connections
468 log("Listening for Telnet connections on %s port %s" % (
472 """Convenience method to get the elapsed time counter."""
473 return self.groups["internal"]["counters"].get("elapsed")
475 def add_group(self, group, fallback=None):
476 """Set up group tracking/metadata."""
477 if group not in self.origins:
478 self.origins[group] = {}
480 fallback = mudpy.data.find_file(
481 ".".join((group, "yaml")), universe=self)
482 if "fallback" not in self.origins[group]:
483 self.origins[group]["fallback"] = fallback
484 flags = self.origins[group].get("flags", None)
485 if fallback not in self.files:
486 mudpy.data.Data(fallback, self, flags=flags)
488 def debug_mode(self):
489 """Boolean method to indicate whether unsafe debugging is enabled."""
490 return self.groups["mudpy"]["limit"].get("debug", False)
495 """This is a connected user."""
498 """Default values for the in-memory user variables."""
501 self.authenticated = False
505 self.connection = None
507 self.input_queue = []
508 self.last_address = ""
509 self.last_input = universe.get_time()
510 self.menu_choices = {}
511 self.menu_seen = False
512 self.negotiation_pause = 0
513 self.output_queue = []
514 self.partial_input = b""
515 self.password_tries = 0
517 self.state = "telopt_negotiation"
520 self.universe = universe
523 """Log, close the connection and remove."""
525 name = self.account.get("name", self)
528 log("Logging out %s" % name, 2)
529 self.deactivate_avatar()
530 self.connection.close()
533 def check_idle(self):
534 """Warn or disconnect idle users as appropriate."""
535 idletime = universe.get_time() - self.last_input
536 linkdead_dict = universe.contents[
537 "mudpy.timing.idle.disconnect"].facets()
538 if self.state in linkdead_dict:
539 linkdead_state = self.state
541 linkdead_state = "default"
542 if idletime > linkdead_dict[linkdead_state]:
544 "$(eol)$(red)You've done nothing for far too long... goodbye!"
549 logline = "Disconnecting "
550 if self.account and self.account.get("name"):
551 logline += self.account.get("name")
553 logline += "an unknown user"
554 logline += (" after idling too long in the " + self.state
557 self.state = "disconnecting"
558 self.menu_seen = False
559 idle_dict = universe.contents["mudpy.timing.idle.warn"].facets()
560 if self.state in idle_dict:
561 idle_state = self.state
563 idle_state = "default"
564 if idletime == idle_dict[idle_state]:
566 "$(eol)$(red)If you continue to be unproductive, "
567 + "you'll be shown the door...$(nrm)$(eol)"
571 """Save, load a new user and relocate the connection."""
573 # copy old attributes
574 attributes = self.__dict__
576 # get out of the list
579 # get rid of the old user object
582 # create a new user object
585 # set everything equivalent
586 new_user.__dict__ = attributes
588 # the avatar needs a new owner
590 new_user.account = universe.contents[new_user.account.key]
591 new_user.avatar = universe.contents[new_user.avatar.key]
592 new_user.avatar.owner = new_user
595 universe.userlist.append(new_user)
597 def replace_old_connections(self):
598 """Disconnect active users with the same name."""
600 # the default return value
603 # iterate over each user in the list
604 for old_user in universe.userlist:
606 # the name is the same but it's not us
609 ) and old_user.account and old_user.account.get(
611 ) == self.account.get(
613 ) and old_user is not self:
617 "User " + self.account.get(
619 ) + " reconnected--closing old connection to "
620 + old_user.address + ".",
624 "$(eol)$(red)New connection from " + self.address
625 + ". Terminating old connection...$(nrm)$(eol)",
630 # close the old connection
631 old_user.connection.close()
633 # replace the old connection with this one
635 "$(eol)$(red)Taking over old connection from "
636 + old_user.address + ".$(nrm)"
638 old_user.connection = self.connection
639 old_user.last_address = old_user.address
640 old_user.address = self.address
642 # take this one out of the list and delete
648 # true if an old connection was replaced, false if not
651 def authenticate(self):
652 """Flag the user as authenticated and disconnect duplicates."""
653 if self.state != "authenticated":
654 self.authenticated = True
655 log("User %s authenticated for account %s." % (
656 self, self.account.subkey), 2)
657 if ("mudpy.limit" in universe.contents and self.account.subkey in
658 universe.contents["mudpy.limit"].get("admins")):
659 self.account.set("administrator", True)
660 log("Account %s is an administrator." % (
661 self.account.subkey), 2)
664 """Send the user their current menu."""
665 if not self.menu_seen:
666 self.menu_choices = get_menu_choices(self)
668 get_menu(self.state, self.error, self.menu_choices),
672 self.menu_seen = True
674 self.adjust_echoing()
677 """"Generate and return an input prompt."""
679 # Start with the user's preference, if one was provided
680 prompt = self.account.get("prompt")
682 # If the user has not set a prompt, then immediately return the default
683 # provided for the current state
685 return get_menu_prompt(self.state)
687 # Allow including the World clock state
688 if "$_(time)" in prompt:
689 prompt = prompt.replace(
691 str(universe.groups["internal"]["counters"].get("elapsed")))
693 # Append a single space for clear separation from user input
694 if prompt[-1] != " ":
695 prompt = "%s " % prompt
697 # Return the cooked prompt
700 def adjust_echoing(self):
701 """Adjust echoing to match state menu requirements."""
702 if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_ECHO,
704 if menu_echo_on(self.state):
705 mudpy.telnet.disable(self, mudpy.telnet.TELOPT_ECHO,
707 elif not menu_echo_on(self.state):
708 mudpy.telnet.enable(self, mudpy.telnet.TELOPT_ECHO,
712 """Remove a user from the list of connected users."""
713 log("Disconnecting account %s." % self, 0)
714 universe.userlist.remove(self)
724 add_terminator=False,
727 """Send arbitrary text to a connected user."""
729 # unless raw mode is on, clean it up all nice and pretty
732 # strip extra $(eol) off if present
733 while output.startswith("$(eol)"):
735 while output.endswith("$(eol)"):
737 extra_lines = output.find("$(eol)$(eol)$(eol)")
738 while extra_lines > -1:
739 output = output[:extra_lines] + output[extra_lines + 6:]
740 extra_lines = output.find("$(eol)$(eol)$(eol)")
742 # start with a newline, append the message, then end
743 # with the optional eol string passed to this function
744 # and the ansi escape to return to normal text
745 if not just_prompt and prepend_padding:
746 if (not self.output_queue or not
747 self.output_queue[-1].endswith(b"\r\n")):
748 output = "$(eol)" + output
749 elif not self.output_queue[-1].endswith(
751 ) and not self.output_queue[-1].endswith(
754 output = "$(eol)" + output
755 output += eol + chr(27) + "[0m"
757 # tack on a prompt if active
758 if self.state == "active":
762 output += self.prompt()
763 mode = self.avatar.get("mode")
765 output += "(" + mode + ") "
767 # find and replace macros in the output
768 output = replace_macros(self, output)
770 # wrap the text at the client's width (min 40, 0 disables)
772 if self.columns < 40:
776 output = wrap_ansi_text(output, wrap)
778 # if supported by the client, encode it utf-8
779 if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_BINARY,
781 encoded_output = output.encode("utf-8")
783 # otherwise just send ascii
785 encoded_output = output.encode("ascii", "replace")
787 # end with a terminator if requested
788 if add_prompt or add_terminator:
789 if mudpy.telnet.is_enabled(
790 self, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US):
791 encoded_output += mudpy.telnet.telnet_proto(
792 mudpy.telnet.IAC, mudpy.telnet.EOR)
793 elif not mudpy.telnet.is_enabled(
794 self, mudpy.telnet.TELOPT_SGA, mudpy.telnet.US):
795 encoded_output += mudpy.telnet.telnet_proto(
796 mudpy.telnet.IAC, mudpy.telnet.GA)
798 # and tack it onto the queue
799 self.output_queue.append(encoded_output)
801 # if this is urgent, flush all pending output
805 # just dump raw bytes as requested
807 self.output_queue.append(output)
811 """All the things to do to the user per increment."""
813 # if the world is terminating, disconnect
814 if universe.terminate_flag:
815 self.state = "disconnecting"
816 self.menu_seen = False
818 # check for an idle connection and act appropriately
822 # ask the client for their current terminal type (RFC 1091); it's None
823 # if it's not been initialized, the empty string if it has but the
824 # output was indeterminate, "UNKNOWN" if the client specified it has no
825 # terminal types to supply
826 if self.ttype is None:
827 mudpy.telnet.request_ttype(self)
829 # if output is paused, decrement the counter
830 if self.state == "telopt_negotiation":
831 if self.negotiation_pause:
832 self.negotiation_pause -= 1
834 self.state = "entering_account_name"
836 # show the user a menu as needed
837 elif not self.state == "active":
840 # flush any pending output in the queue
843 # disconnect users with the appropriate state
844 if self.state == "disconnecting":
847 # check for input and add it to the queue
850 # there is input waiting in the queue
852 handle_user_input(self)
855 """Try to send the last item in the queue and remove it."""
856 if self.output_queue:
858 self.connection.send(self.output_queue[0])
859 except (BrokenPipeError, ConnectionResetError):
860 if self.account and self.account.get("name"):
861 account = self.account.get("name")
863 account = "an unknown user"
864 self.state = "disconnecting"
865 log("Disconnected while sending to %s." % account, 7)
866 del self.output_queue[0]
868 def enqueue_input(self):
869 """Process and enqueue any new input."""
871 # check for some input
873 raw_input = self.connection.recv(1024)
880 # tack this on to any previous partial
881 self.partial_input += raw_input
883 # reply to and remove any IAC negotiation codes
884 mudpy.telnet.negotiate_telnet_options(self)
886 # separate multiple input lines
887 new_input_lines = self.partial_input.split(b"\r\0")
888 if len(new_input_lines) == 1:
889 new_input_lines = new_input_lines[0].split(b"\r\n")
891 # if input doesn't end in a newline, replace the
892 # held partial input with the last line of it
894 self.partial_input.endswith(b"\r\0") or
895 self.partial_input.endswith(b"\r\n")):
896 self.partial_input = new_input_lines.pop()
898 # otherwise, chop off the extra null input and reset
899 # the held partial input
901 new_input_lines.pop()
902 self.partial_input = b""
904 # iterate over the remaining lines
905 for line in new_input_lines:
907 # strip off extra whitespace
910 # log non-printable characters remaining
911 if not mudpy.telnet.is_enabled(
912 self, mudpy.telnet.TELOPT_BINARY, mudpy.telnet.HIM):
913 asciiline = bytes([x for x in line if 32 <= x <= 126])
914 if line != asciiline:
915 logline = "Non-ASCII characters from "
916 if self.account and self.account.get("name"):
917 logline += self.account.get("name") + ": "
919 logline += "unknown user: "
920 logline += repr(line)
925 line = line.decode("utf-8")
926 except UnicodeDecodeError:
927 logline = "Non-UTF-8 sequence from "
928 if self.account and self.account.get("name"):
929 logline += self.account.get("name") + ": "
931 logline += "unknown user: "
932 logline += repr(line)
936 line = unicodedata.normalize("NFKC", line)
938 # put on the end of the queue
939 self.input_queue.append(line)
941 def new_avatar(self):
942 """Instantiate a new, unconfigured avatar for this user."""
944 while ("avatar_%s_%s" % (self.account.get("name"), counter)
945 in universe.groups.get("actor", {}).keys()):
947 self.avatar = Element(
948 "actor.avatar_%s_%s" % (self.account.get("name"), counter),
950 self.avatar.append("inherit", "archetype.avatar")
951 self.account.append("avatars", self.avatar.key)
952 log("Created new avatar %s for user %s." % (
953 self.avatar.key, self.account.get("name")), 0)
955 def delete_avatar(self, avatar):
956 """Remove an avatar from the world and from the user's list."""
957 if self.avatar is universe.contents[avatar]:
959 log("Deleting avatar %s for user %s." % (
960 avatar, self.account.get("name")), 0)
961 universe.contents[avatar].destroy()
962 avatars = self.account.get("avatars")
963 avatars.remove(avatar)
964 self.account.set("avatars", avatars)
966 def activate_avatar_by_index(self, index):
967 """Enter the world with a particular indexed avatar."""
968 self.avatar = universe.contents[
969 self.account.get("avatars")[index]]
970 self.avatar.owner = self
971 self.state = "active"
972 log("Activated avatar %s (%s)." % (
973 self.avatar.get("name"), self.avatar.key), 0)
974 self.avatar.go_home()
976 def deactivate_avatar(self):
977 """Have the active avatar leave the world."""
979 log("Deactivating avatar %s (%s) for user %s." % (
980 self.avatar.get("name"), self.avatar.key,
981 self.account.get("name")), 0)
982 current = self.avatar.get("location")
984 self.avatar.set("default_location", current)
985 self.avatar.echo_to_location(
986 "You suddenly wonder where " + self.avatar.get(
990 del universe.contents[current].contents[self.avatar.key]
991 self.avatar.remove_facet("location")
992 self.avatar.owner = None
996 """Destroy the user and associated avatars."""
997 for avatar in self.account.get("avatars"):
998 self.delete_avatar(avatar)
999 log("Destroying account %s for user %s." % (
1000 self.account.get("name"), self), 0)
1001 self.account.destroy()
1003 def list_avatar_names(self):
1004 """List names of assigned avatars."""
1006 for avatar in self.account.get("avatars"):
1008 avatars.append(universe.contents[avatar].get("name"))
1010 log('Missing avatar "%s", possible data corruption.' %
1015 """Boolean check whether user's account is an admin."""
1016 return self.account.get("administrator", False)
1019 def broadcast(message, add_prompt=True):
1020 """Send a message to all connected users."""
1021 for each_user in universe.userlist:
1022 each_user.send("$(eol)" + message, add_prompt=add_prompt)
1025 def log(message, level=0):
1026 """Log a message."""
1028 # a couple references we need
1029 if "mudpy.log" in universe.contents:
1030 file_name = universe.contents["mudpy.log"].get("file", "")
1031 max_log_lines = universe.contents["mudpy.log"].get("lines", 0)
1032 syslog_name = universe.contents["mudpy.log"].get("syslog", "")
1037 timestamp = datetime.datetime.now().isoformat(' ')
1039 # turn the message into a list of nonempty lines
1040 lines = [x for x in [(x.rstrip()) for x in message.split("\n")] if x != ""]
1042 # send the timestamp and line to a file
1044 if not os.path.isabs(file_name):
1045 file_name = os.path.join(universe.startdir, file_name)
1046 os.makedirs(os.path.dirname(file_name), exist_ok=True)
1047 file_descriptor = codecs.open(file_name, "a", "utf-8")
1049 file_descriptor.write(timestamp + " " + line + "\n")
1050 file_descriptor.flush()
1051 file_descriptor.close()
1053 # send the timestamp and line to standard output
1054 if ("mudpy.log" in universe.contents and
1055 universe.contents["mudpy.log"].get("stdout")):
1057 print(timestamp + " " + line)
1059 # send the line to the system log
1062 syslog_name.encode("utf-8"),
1064 syslog.LOG_INFO | syslog.LOG_DAEMON
1070 # display to connected administrators
1071 for user in universe.userlist:
1073 user.state == "active"
1075 and user.account.get("loglevel", 0) <= level):
1076 # iterate over every line in the message
1080 "$(bld)$(red)" + timestamp + " "
1081 + line.replace("$(", "$_(") + "$(nrm)$(eol)")
1082 user.send(full_message, flush=True)
1084 # add to the recent log list
1086 while 0 < len(universe.loglines) >= max_log_lines:
1087 del universe.loglines[0]
1088 universe.loglines.append((timestamp + " " + line, level))
1091 def get_loglines(level, start, stop):
1092 """Return a specific range of loglines filtered by level."""
1094 # filter the log lines
1095 loglines = [x for x in universe.loglines if x[1] >= level]
1097 # we need these in several places
1098 total_count = str(len(universe.loglines))
1099 filtered_count = len(loglines)
1101 # don't proceed if there are no lines
1104 # can't start before the beginning or at the end
1105 if start > filtered_count:
1106 start = filtered_count
1110 # can't stop before we start
1118 "There are %s log lines in memory and %s at or above level %s. "
1119 "The matching lines from %s to %s are:$(eol)$(eol)" %
1120 (total_count, filtered_count, level, stop, start))
1122 # add the text from the selected lines
1124 range_lines = loglines[-start:-(stop - 1)]
1126 range_lines = loglines[-start:]
1127 for line in range_lines:
1128 message += " (%s) %s$(eol)" % (
1129 line[1], line[0].replace("$(", "$_("))
1131 # there were no lines
1133 message = "None of the %s lines in memory matches your request." % (
1140 def glyph_columns(character):
1141 """Convenience function to return the column width of a glyph."""
1142 if unicodedata.east_asian_width(character) in "FW":
1148 def wrap_ansi_text(text, width):
1149 """Wrap text with arbitrary width while ignoring ANSI colors."""
1151 # the current position in the entire text string, including all
1152 # characters, printable or otherwise
1155 # the current text position relative to the beginning of the line,
1156 # ignoring color escape sequences
1159 # the absolute and relative positions of the most recent whitespace
1161 last_abs_whitespace = 0
1162 last_rel_whitespace = 0
1164 # whether the current character is part of a color escape sequence
1167 # normalize any potentially composited unicode before we count it
1168 text = unicodedata.normalize("NFKC", text)
1170 # iterate over each character from the beginning of the text
1171 for each_character in text:
1173 # the current character is the escape character
1174 if each_character == "\x1b" and not escape:
1178 # the current character is within an escape sequence
1181 if each_character == "m":
1182 # the current character is m, which terminates the
1186 # the current character is a space
1187 elif each_character == " ":
1188 last_abs_whitespace = abs_pos
1189 last_rel_whitespace = rel_pos
1191 # the current character is a newline, so reset the relative
1192 # position too (start a new line)
1193 elif each_character == "\n":
1195 last_abs_whitespace = abs_pos
1196 last_rel_whitespace = rel_pos
1198 # the current character meets the requested maximum line width, so we
1199 # need to wrap unless the current word is wider than the terminal (in
1200 # which case we let it do the wrapping instead)
1201 if last_rel_whitespace != 0 and (rel_pos > width or (
1202 rel_pos > width - 1 and glyph_columns(each_character) == 2)):
1204 # insert an eol in place of the last space
1205 text = (text[:last_abs_whitespace] + "\r\n" +
1206 text[last_abs_whitespace + 1:])
1208 # increase the absolute position because an eol is two
1209 # characters but the space it replaced was only one
1212 # now we're at the beginning of a new line, plus the
1213 # number of characters wrapped from the previous line
1214 rel_pos -= last_rel_whitespace
1215 last_rel_whitespace = 0
1217 # as long as the character is not a carriage return and the
1218 # other above conditions haven't been met, count it as a
1219 # printable character
1220 elif each_character != "\r":
1221 rel_pos += glyph_columns(each_character)
1222 if each_character in (" ", "\n"):
1223 last_abs_whitespace = abs_pos
1224 last_rel_whitespace = rel_pos
1226 # increase the absolute position for every character
1229 # return the newly-wrapped text
1233 def weighted_choice(data):
1234 """Takes a dict weighted by value and returns a random key."""
1236 # this will hold our expanded list of keys from the data
1239 # create the expanded list of keys
1240 for key in data.keys():
1241 for _count in range(data[key]):
1242 expanded.append(key)
1244 # return one at random
1245 # Allow the random.randrange() call in bandit since it's not used for
1246 # security/cryptographic purposes
1247 return random.choice(expanded) # nosec
1251 """Returns a random character name."""
1253 # the vowels and consonants needed to create romaji syllables
1282 # this dict will hold our weighted list of syllables
1285 # generate the list with an even weighting
1286 for consonant in consonants:
1287 for vowel in vowels:
1288 syllables[consonant + vowel] = 1
1290 # we'll build the name into this string
1293 # create a name of random length from the syllables
1294 # Allow the random.randrange() call in bandit since it's not used for
1295 # security/cryptographic purposes
1296 for _syllable in range(random.randrange(2, 6)): # nosec
1297 name += weighted_choice(syllables)
1299 # strip any leading quotemark, capitalize and return the name
1300 return name.strip("'").capitalize()
1303 def replace_macros(user, text, is_input=False):
1304 """Replaces macros in text output."""
1306 # third person pronouns
1308 "female": {"obj": "her", "pos": "hers", "sub": "she"},
1309 "male": {"obj": "him", "pos": "his", "sub": "he"},
1310 "neuter": {"obj": "it", "pos": "its", "sub": "it"}
1313 # a dict of replacement macros
1316 "bld": chr(27) + "[1m",
1317 "nrm": chr(27) + "[0m",
1318 "blk": chr(27) + "[30m",
1319 "blu": chr(27) + "[34m",
1320 "cyn": chr(27) + "[36m",
1321 "grn": chr(27) + "[32m",
1322 "mgt": chr(27) + "[35m",
1323 "red": chr(27) + "[31m",
1324 "yel": chr(27) + "[33m",
1327 # add dynamic macros where possible
1329 account_name = user.account.get("name")
1331 macros["account"] = account_name
1333 avatar_gender = user.avatar.get("gender")
1335 macros["tpop"] = pronouns[avatar_gender]["obj"]
1336 macros["tppp"] = pronouns[avatar_gender]["pos"]
1337 macros["tpsp"] = pronouns[avatar_gender]["sub"]
1342 # find and replace per the macros dict
1343 macro_start = text.find("$(")
1344 if macro_start == -1:
1346 macro_end = text.find(")", macro_start) + 1
1347 macro = text[macro_start + 2:macro_end - 1]
1348 if macro in macros.keys():
1349 replacement = macros[macro]
1351 # this is how we handle local file inclusion (dangerous!)
1352 elif macro.startswith("inc:"):
1353 incfile = mudpy.data.find_file(macro[4:], universe=universe)
1354 if os.path.exists(incfile):
1355 incfd = codecs.open(incfile, "r", "utf-8")
1358 if line.endswith("\n") and not line.endswith("\r\n"):
1359 line = line.replace("\n", "\r\n")
1361 # lose the trailing eol
1362 replacement = replacement[:-2]
1365 log("Couldn't read included " + incfile + " file.", 7)
1367 # if we get here, log and replace it with null
1371 log("Unexpected replacement macro " +
1372 macro + " encountered.", 6)
1374 # and now we act on the replacement
1375 text = text.replace("$(" + macro + ")", replacement)
1377 # replace the look-like-a-macro sequence
1378 text = text.replace("$_(", "$(")
1383 def escape_macros(value):
1384 """Escapes replacement macros in text."""
1385 if type(value) is str:
1386 return value.replace("$(", "$_(")
1391 def first_word(text, separator=" "):
1392 """Returns a tuple of the first word and the rest."""
1394 if text.find(separator) > 0:
1395 return text.split(separator, 1)
1403 """The things which should happen on each pulse, aside from reloads."""
1405 # open the listening socket if it hasn't been already
1406 if not hasattr(universe, "listening_socket"):
1407 universe.initialize_server_socket()
1409 # assign a user if a new connection is waiting
1410 user = check_for_connection(universe.listening_socket)
1412 universe.userlist.append(user)
1414 # iterate over the connected users
1415 for user in universe.userlist:
1418 # add an element for counters if it doesn't exist
1419 if "counters" not in universe.groups.get("internal", {}):
1420 Element("internal.counters", universe)
1422 # update the log every now and then
1423 if not universe.groups["internal"]["counters"].get("mark"):
1424 log(str(len(universe.userlist)) + " connection(s)")
1425 universe.groups["internal"]["counters"].set(
1426 "mark", universe.contents["mudpy.timing"].get("status")
1429 universe.groups["internal"]["counters"].set(
1430 "mark", universe.groups["internal"]["counters"].get(
1435 # periodically save everything
1436 if not universe.groups["internal"]["counters"].get("save"):
1438 universe.groups["internal"]["counters"].set(
1439 "save", universe.contents["mudpy.timing"].get("save")
1442 universe.groups["internal"]["counters"].set(
1443 "save", universe.groups["internal"]["counters"].get(
1448 # pause for a configurable amount of time (decimal seconds)
1449 time.sleep(universe.contents["mudpy.timing"].get("increment"))
1451 # increase the elapsed increment counter
1452 universe.groups["internal"]["counters"].set(
1453 "elapsed", universe.groups["internal"]["counters"].get(
1460 """Reload all relevant objects."""
1462 old_userlist = universe.userlist[:]
1463 old_loglines = universe.loglines[:]
1464 for element in list(universe.contents.values()):
1466 pending_loglines = universe.load()
1467 new_loglines = universe.loglines[:]
1468 universe.loglines = old_loglines + new_loglines + pending_loglines
1469 for user in old_userlist:
1473 def check_for_connection(listening_socket):
1474 """Check for a waiting connection and return a new user object."""
1476 # try to accept a new connection
1478 connection, address = listening_socket.accept()
1479 except BlockingIOError:
1482 # note that we got one
1483 log("New connection from %s." % address[0], 2)
1485 # disable blocking so we can proceed whether or not we can send/receive
1486 connection.setblocking(0)
1488 # create a new user object
1490 log("Instantiated %s for %s." % (user, address[0]), 0)
1492 # associate this connection with it
1493 user.connection = connection
1495 # set the user's ipa from the connection's ipa
1496 user.address = address[0]
1498 # let the client know we WILL EOR (RFC 885)
1499 mudpy.telnet.enable(user, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US)
1500 user.negotiation_pause = 2
1502 # return the new user object
1506 def find_command(command_name):
1507 """Try to find a command by name or abbreviation."""
1509 # lowercase the command
1510 command_name = command_name.lower()
1513 if command_name in universe.groups["command"]:
1514 # the command matches a command word for which we have data
1515 command = universe.groups["command"][command_name]
1517 for candidate in sorted(universe.groups["command"]):
1518 if candidate.startswith(command_name) and not universe.groups[
1519 "command"][candidate].is_restricted():
1520 # the command matches the start of a command word and is not
1521 # restricted to administrators
1522 command = universe.groups["command"][candidate]
1527 def get_menu(state, error=None, choices=None):
1528 """Show the correct menu text to a user."""
1530 # make sure we don't reuse a mutable sequence by default
1534 # get the description or error text
1535 message = get_menu_description(state, error)
1537 # get menu choices for the current state
1538 message += get_formatted_menu_choices(state, choices)
1540 # try to get a prompt, if it was defined
1541 message += get_menu_prompt(state)
1543 # throw in the default choice, if it exists
1544 message += get_formatted_default_menu_choice(state)
1546 # display a message indicating if echo is off
1547 message += get_echo_message(state)
1549 # return the assembly of various strings defined above
1553 def menu_echo_on(state):
1554 """True if echo is on, false if it is off."""
1555 return universe.groups["menu"][state].get("echo", True)
1558 def get_echo_message(state):
1559 """Return a message indicating that echo is off."""
1560 if menu_echo_on(state):
1563 return "(won't echo) "
1566 def get_default_menu_choice(state):
1567 """Return the default choice for a menu."""
1568 return universe.groups["menu"][state].get("default")
1571 def get_formatted_default_menu_choice(state):
1572 """Default menu choice foratted for inclusion in a prompt string."""
1573 default_choice = get_default_menu_choice(state)
1575 return "[$(red)" + default_choice + "$(nrm)] "
1580 def get_menu_description(state, error):
1581 """Get the description or error text."""
1583 # an error condition was raised by the handler
1586 # try to get an error message matching the condition
1588 description = universe.groups[
1589 "menu"][state].get("error_" + error)
1591 description = "That is not a valid choice..."
1592 description = "$(red)" + description + "$(nrm)"
1594 # there was no error condition
1597 # try to get a menu description for the current state
1598 description = universe.groups["menu"][state].get("description")
1600 # return the description or error message
1602 description += "$(eol)$(eol)"
1606 def get_menu_prompt(state):
1607 """Try to get a prompt, if it was defined."""
1608 prompt = universe.groups["menu"][state].get("prompt")
1614 def get_menu_choices(user):
1615 """Return a dict of choice:meaning."""
1616 state = universe.groups["menu"][user.state]
1617 create_choices = state.get("create")
1619 choices = call_hook_function(create_choices, (user,))
1625 for facet in state.facets():
1626 if facet.startswith("demand_") and not call_hook_function(
1627 universe.groups["menu"][user.state].get(facet), (user,)):
1628 ignores.append(facet.split("_", 2)[1])
1629 elif facet.startswith("create_"):
1630 creates[facet] = facet.split("_", 2)[1]
1631 elif facet.startswith("choice_"):
1632 options[facet] = facet.split("_", 2)[1]
1633 for facet in creates.keys():
1634 if not creates[facet] in ignores:
1635 choices[creates[facet]] = call_hook_function(
1636 state.get(facet), (user,))
1637 for facet in options.keys():
1638 if not options[facet] in ignores:
1639 choices[options[facet]] = state.get(facet)
1643 def get_formatted_menu_choices(state, choices):
1644 """Returns a formatted string of menu choices."""
1646 choice_keys = list(choices.keys())
1648 for choice in choice_keys:
1649 choice_output += " [$(red)" + choice + "$(nrm)] " + choices[
1653 choice_output += "$(eol)"
1654 return choice_output
1657 def get_menu_branches(state):
1658 """Return a dict of choice:branch."""
1660 for facet in universe.groups["menu"][state].facets():
1661 if facet.startswith("branch_"):
1663 facet.split("_", 2)[1]
1664 ] = universe.groups["menu"][state].get(facet)
1668 def get_default_branch(state):
1669 """Return the default branch."""
1670 return universe.groups["menu"][state].get("branch")
1673 def get_choice_branch(user):
1674 """Returns the new state matching the given choice."""
1675 branches = get_menu_branches(user.state)
1676 if user.choice in branches.keys():
1677 return branches[user.choice]
1678 elif user.choice in user.menu_choices.keys():
1679 return get_default_branch(user.state)
1684 def get_menu_actions(state):
1685 """Return a dict of choice:branch."""
1687 for facet in universe.groups["menu"][state].facets():
1688 if facet.startswith("action_"):
1690 facet.split("_", 2)[1]
1691 ] = universe.groups["menu"][state].get(facet)
1695 def get_default_action(state):
1696 """Return the default action."""
1697 return universe.groups["menu"][state].get("action")
1700 def get_choice_action(user):
1701 """Run any indicated script for the given choice."""
1702 actions = get_menu_actions(user.state)
1703 if user.choice in actions.keys():
1704 return actions[user.choice]
1705 elif user.choice in user.menu_choices.keys():
1706 return get_default_action(user.state)
1711 def call_hook_function(fname, arglist):
1712 """Safely execute named function with supplied arguments, return result."""
1714 # all functions relative to mudpy package
1717 for component in fname.split("."):
1719 function = getattr(function, component)
1720 except AttributeError:
1721 log('Could not find mudpy.%s() for arguments "%s"'
1722 % (fname, arglist), 7)
1727 return function(*arglist)
1729 log('Calling mudpy.%s(%s) raised an exception...\n%s'
1730 % (fname, (*arglist,), traceback.format_exc()), 7)
1733 def handle_user_input(user):
1734 """The main handler, branches to a state-specific handler."""
1736 # if the user's client echo is off, send a blank line for aesthetics
1737 if mudpy.telnet.is_enabled(user, mudpy.telnet.TELOPT_ECHO,
1739 user.send("", add_prompt=False, prepend_padding=False)
1741 # check to make sure the state is expected, then call that handler
1743 globals()["handler_" + user.state](user)
1745 generic_menu_handler(user)
1747 # since we got input, flag that the menu/prompt needs to be redisplayed
1748 user.menu_seen = False
1750 # update the last_input timestamp while we're at it
1751 user.last_input = universe.get_time()
1754 def generic_menu_handler(user):
1755 """A generic menu choice handler."""
1757 # get a lower-case representation of the next line of input
1758 if user.input_queue:
1759 user.choice = user.input_queue.pop(0)
1761 user.choice = user.choice.lower()
1765 user.choice = get_default_menu_choice(user.state)
1766 if user.choice in user.menu_choices:
1767 action = get_choice_action(user)
1769 call_hook_function(action, (user,))
1770 new_state = get_choice_branch(user)
1772 user.state = new_state
1774 user.error = "default"
1777 def handler_entering_account_name(user):
1778 """Handle the login account name."""
1780 # get the next waiting line of input
1781 input_data = user.input_queue.pop(0)
1783 # did the user enter anything?
1786 # keep only the first word and convert to lower-case
1787 name = input_data.lower()
1789 # fail if there are non-alphanumeric characters
1790 if name != "".join(filter(
1791 lambda x: x >= "0" and x <= "9" or x >= "a" and x <= "z",
1793 user.error = "bad_name"
1795 # if that account exists, time to request a password
1796 elif name in universe.groups.get("account", {}):
1797 user.account = universe.groups["account"][name]
1798 user.state = "checking_password"
1800 # otherwise, this could be a brand new user
1802 user.account = Element("account.%s" % name, universe)
1803 user.account.set("name", name)
1804 log("New user: " + name, 2)
1805 user.state = "checking_new_account_name"
1807 # if the user entered nothing for a name, then buhbye
1809 user.state = "disconnecting"
1812 def handler_checking_password(user):
1813 """Handle the login account password."""
1815 # get the next waiting line of input
1816 input_data = user.input_queue.pop(0)
1818 if "mudpy.limit" in universe.contents:
1819 max_password_tries = universe.contents["mudpy.limit"].get(
1820 "password_tries", 3)
1822 max_password_tries = 3
1824 # does the hashed input equal the stored hash?
1825 if mudpy.password.verify(input_data, user.account.get("passhash")):
1827 # if so, set the username and load from cold storage
1828 if not user.replace_old_connections():
1830 user.state = "main_utility"
1832 # if at first your hashes don't match, try, try again
1833 elif user.password_tries < max_password_tries - 1:
1834 user.password_tries += 1
1835 user.error = "incorrect"
1837 # we've exceeded the maximum number of password failures, so disconnect
1840 "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1842 user.state = "disconnecting"
1845 def handler_entering_new_password(user):
1846 """Handle a new password entry."""
1848 # get the next waiting line of input
1849 input_data = user.input_queue.pop(0)
1851 if "mudpy.limit" in universe.contents:
1852 max_password_tries = universe.contents["mudpy.limit"].get(
1853 "password_tries", 3)
1855 max_password_tries = 3
1857 # make sure the password is strong--at least one upper, one lower and
1858 # one digit, seven or more characters in length
1859 if len(input_data) > 6 and len(
1860 list(filter(lambda x: x >= "0" and x <= "9", input_data))
1862 list(filter(lambda x: x >= "A" and x <= "Z", input_data))
1864 list(filter(lambda x: x >= "a" and x <= "z", input_data))
1867 # hash and store it, then move on to verification
1868 user.account.set("passhash", mudpy.password.create(input_data))
1869 user.state = "verifying_new_password"
1871 # the password was weak, try again if you haven't tried too many times
1872 elif user.password_tries < max_password_tries - 1:
1873 user.password_tries += 1
1876 # too many tries, so adios
1879 "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1881 user.account.destroy()
1882 user.state = "disconnecting"
1885 def handler_verifying_new_password(user):
1886 """Handle the re-entered new password for verification."""
1888 # get the next waiting line of input
1889 input_data = user.input_queue.pop(0)
1891 if "mudpy.limit" in universe.contents:
1892 max_password_tries = universe.contents["mudpy.limit"].get(
1893 "password_tries", 3)
1895 max_password_tries = 3
1897 # hash the input and match it to storage
1898 if mudpy.password.verify(input_data, user.account.get("passhash")):
1901 # the hashes matched, so go active
1902 if not user.replace_old_connections():
1903 user.state = "main_utility"
1905 # go back to entering the new password as long as you haven't tried
1907 elif user.password_tries < max_password_tries - 1:
1908 user.password_tries += 1
1909 user.error = "differs"
1910 user.state = "entering_new_password"
1912 # otherwise, sayonara
1915 "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1917 user.account.destroy()
1918 user.state = "disconnecting"
1921 def handler_active(user):
1922 """Handle input for active users."""
1924 # get the next waiting line of input
1925 input_data = user.input_queue.pop(0)
1930 # split out the command and parameters
1932 mode = actor.get("mode")
1933 if mode and input_data.startswith("!"):
1934 command_name, parameters = first_word(input_data[1:])
1935 elif mode == "chat":
1936 command_name = "say"
1937 parameters = input_data
1939 command_name, parameters = first_word(input_data)
1941 # expand to an actual command
1942 command = find_command(command_name)
1944 # if it's allowed, do it
1946 if actor.can_run(command):
1947 action_fname = command.get("action", command.key)
1949 result = call_hook_function(action_fname, (actor, parameters))
1951 # if the command was not run, give an error
1953 mudpy.command.error(actor, input_data)
1955 # if no input, just idle back with a prompt
1957 user.send("", just_prompt=True)
1960 def daemonize(universe):
1961 """Fork and disassociate from everything."""
1963 # only if this is what we're configured to do
1964 if "mudpy.process" in universe.contents and universe.contents[
1965 "mudpy.process"].get("daemon"):
1967 # log before we start forking around, so the terminal gets the message
1968 log("Disassociating from the controlling terminal.")
1970 # fork off and die, so we free up the controlling terminal
1974 # switch to a new process group
1977 # fork some more, this time to free us from the old process group
1981 # reset the working directory so we don't needlessly tie up mounts
1984 # clear the file creation mask so we can bend it to our will later
1987 # redirect stdin/stdout/stderr and close off their former descriptors
1988 for stdpipe in range(3):
1990 sys.stdin = codecs.open("/dev/null", "r", "utf-8")
1991 sys.__stdin__ = codecs.open("/dev/null", "r", "utf-8")
1992 sys.stdout = codecs.open("/dev/null", "w", "utf-8")
1993 sys.stderr = codecs.open("/dev/null", "w", "utf-8")
1994 sys.__stdout__ = codecs.open("/dev/null", "w", "utf-8")
1995 sys.__stderr__ = codecs.open("/dev/null", "w", "utf-8")
1998 def create_pidfile(universe):
1999 """Write a file containing the current process ID."""
2000 pid = str(os.getpid())
2001 log("Process ID: " + pid)
2002 if "mudpy.process" in universe.contents:
2003 file_name = universe.contents["mudpy.process"].get("pidfile", "")
2007 if not os.path.isabs(file_name):
2008 file_name = os.path.join(universe.startdir, file_name)
2009 os.makedirs(os.path.dirname(file_name), exist_ok=True)
2010 file_descriptor = codecs.open(file_name, "w", "utf-8")
2011 file_descriptor.write(pid + "\n")
2012 file_descriptor.flush()
2013 file_descriptor.close()
2016 def remove_pidfile(universe):
2017 """Remove the file containing the current process ID."""
2018 if "mudpy.process" in universe.contents:
2019 file_name = universe.contents["mudpy.process"].get("pidfile", "")
2023 if not os.path.isabs(file_name):
2024 file_name = os.path.join(universe.startdir, file_name)
2025 if os.access(file_name, os.W_OK):
2026 os.remove(file_name)
2029 def excepthook(excepttype, value, tracebackdata):
2030 """Handle uncaught exceptions."""
2032 # assemble the list of errors into a single string
2034 traceback.format_exception(excepttype, value, tracebackdata)
2037 # try to log it, if possible
2040 except Exception as e:
2041 # try to write it to stderr, if possible
2042 sys.stderr.write(message + "\nException while logging...\n%s" % e)
2045 def sighook(what, where):
2046 """Handle external signals."""
2049 message = "Caught signal: "
2051 # for a hangup signal
2052 if what == signal.SIGHUP:
2053 message += "hangup (reloading)"
2054 universe.reload_flag = True
2056 # for a terminate signal
2057 elif what == signal.SIGTERM:
2058 message += "terminate (halting)"
2059 universe.terminate_flag = True
2061 # catchall for unexpected signals
2063 message += str(what) + " (unhandled)"
2069 def override_excepthook():
2070 """Redefine sys.excepthook with our own."""
2071 sys.excepthook = excepthook
2074 def assign_sighook():
2075 """Assign a customized handler for some signals."""
2076 signal.signal(signal.SIGHUP, sighook)
2077 signal.signal(signal.SIGTERM, sighook)
2081 """This contains functions to be performed when starting the engine."""
2083 # see if a configuration file was specified
2084 if len(sys.argv) > 1:
2085 conffile = sys.argv[1]
2091 universe = Universe(conffile, True)
2093 # report any loglines which accumulated during setup
2094 for logline in universe.setup_loglines:
2096 universe.setup_loglines = []
2098 # fork and disassociate
2101 # override the default exception handler so we get logging first thing
2102 override_excepthook()
2104 # set up custom signal handlers
2108 create_pidfile(universe)
2110 # load and store diagnostic info
2111 universe.versions = mudpy.version.Versions("mudpy")
2113 # log startup diagnostic messages
2114 log("On %s at %s" % (universe.versions.python_version, sys.executable), 1)
2115 log("Import path: %s" % ", ".join(sys.path), 1)
2116 log("Installed dependencies: %s" % universe.versions.dependencies_text, 1)
2117 log("Other python packages: %s" % universe.versions.environment_text, 1)
2118 log("Running version: %s" % universe.versions.version, 1)
2119 log("Initial directory: %s" % universe.startdir, 1)
2120 log("Command line: %s" % " ".join(sys.argv), 1)
2121 if universe.debug_mode():
2122 log("WARNING: Unsafe debugging mode is enabled!", 6)
2124 # pass the initialized universe back
2129 """These are functions performed when shutting down the engine."""
2131 # the loop has terminated, so save persistent data
2134 # log a final message
2135 log("Shutting down now.")
2137 # get rid of the pidfile
2138 remove_pidfile(universe)