Disconnect user on ConnectionResetError
[mudpy.git] / mudpy / misc.py
1 """Miscellaneous functions for the mudpy engine."""
2
3 # Copyright (c) 2004-2017 Jeremy Stanley <fungi@yuggoth.org>. Permission
4 # to use, copy, modify, and distribute this software is granted under
5 # terms provided in the LICENSE file distributed with this software.
6
7 import codecs
8 import os
9 import random
10 import re
11 import signal
12 import socket
13 import sys
14 import syslog
15 import time
16 import traceback
17 import unicodedata
18
19 import mudpy
20
21
22 class Element:
23
24     """An element of the universe."""
25
26     def __init__(self, key, universe, origin=None):
27         """Set up a new element."""
28
29         # keep track of our key name
30         self.key = key
31
32         # keep track of what universe it's loading into
33         self.universe = universe
34
35         # set of facet keys from the universe
36         self.facethash = dict()
37
38         # not owned by a user by default (used for avatars)
39         self.owner = None
40
41         # no contents in here by default
42         self.contents = {}
43
44         if self.key.find(".") > 0:
45             self.group, self.subkey = self.key.split(".")[-2:]
46         else:
47             self.group = "other"
48             self.subkey = self.key
49         if self.group not in self.universe.groups:
50             self.universe.groups[self.group] = {}
51
52         # get an appropriate origin
53         if not origin:
54             self.universe.add_group(self.group)
55             origin = self.universe.files[
56                     self.universe.origins[self.group]["fallback"]]
57
58         # record or reset a pointer to the origin file
59         self.origin = self.universe.files[origin.source]
60
61         # add or replace this element in the universe
62         self.universe.contents[self.key] = self
63         self.universe.groups[self.group][self.subkey] = self
64
65     def reload(self):
66         """Create a new element and replace this one."""
67         Element(self.key, self.universe, self.origin)
68         del(self)
69
70     def destroy(self):
71         """Remove an element from the universe and destroy it."""
72         for facet in dict(self.facethash):
73             self.remove_facet(facet)
74         del self.universe.groups[self.group][self.subkey]
75         del self.universe.contents[self.key]
76         del self
77
78     def facets(self):
79         """Return a list of non-inherited facets for this element."""
80         return self.facethash
81
82     def has_facet(self, facet):
83         """Return whether the non-inherited facet exists."""
84         return facet in self.facets()
85
86     def remove_facet(self, facet):
87         """Remove a facet from the element."""
88         if ".".join((self.key, facet)) in self.origin.data:
89             del self.origin.data[".".join((self.key, facet))]
90         if facet in self.facethash:
91             del self.facethash[facet]
92         self.origin.modified = True
93
94     def ancestry(self):
95         """Return a list of the element's inheritance lineage."""
96         if self.has_facet("inherit"):
97             ancestry = self.get("inherit")
98             if not ancestry:
99                 ancestry = []
100             for parent in ancestry[:]:
101                 ancestors = self.universe.contents[parent].ancestry()
102                 for ancestor in ancestors:
103                     if ancestor not in ancestry:
104                         ancestry.append(ancestor)
105             return ancestry
106         else:
107             return []
108
109     def get(self, facet, default=None):
110         """Retrieve values."""
111         if default is None:
112             default = ""
113         try:
114             return self.origin.data[".".join((self.key, facet))]
115         except (KeyError, TypeError):
116             pass
117         if self.has_facet("inherit"):
118             for ancestor in self.ancestry():
119                 if self.universe.contents[ancestor].has_facet(facet):
120                     return self.universe.contents[ancestor].get(facet)
121         else:
122             return default
123
124     def set(self, facet, value):
125         """Set values."""
126         if not self.origin.is_writeable() and not self.universe.loading:
127             # break if there is an attempt to update an element from a
128             # read-only file, unless the universe is in the midst of loading
129             # updated data from files
130             raise PermissionError("Altering elements in read-only files is "
131                                   "disallowed")
132         # Coerce some values to appropriate data types
133         # TODO(fungi) Move these to a separate validation mechanism
134         if facet in ["loglevel"]:
135             value = int(value)
136         elif facet in ["administrator"]:
137             value = bool(value)
138
139         # The canonical node for this facet within its origin
140         node = ".".join((self.key, facet))
141
142         if node not in self.origin.data or self.origin.data[node] != value:
143             # Be careful to only update the origin's contents when required,
144             # since that affects whether the backing file gets written
145             self.origin.data[node] = value
146             self.origin.modified = True
147
148         # Make sure this facet is included in the element's facets
149         self.facethash[facet] = self.origin.data[node]
150
151     def append(self, facet, value):
152         """Append value to a list."""
153         newlist = self.get(facet)
154         if not newlist:
155             newlist = []
156         if type(newlist) is not list:
157             newlist = list(newlist)
158         newlist.append(value)
159         self.set(facet, newlist)
160
161     def send(
162         self,
163         message,
164         eol="$(eol)",
165         raw=False,
166         flush=False,
167         add_prompt=True,
168         just_prompt=False,
169         add_terminator=False,
170         prepend_padding=True
171     ):
172         """Convenience method to pass messages to an owner."""
173         if self.owner:
174             self.owner.send(
175                 message,
176                 eol,
177                 raw,
178                 flush,
179                 add_prompt,
180                 just_prompt,
181                 add_terminator,
182                 prepend_padding
183             )
184
185     def can_run(self, command):
186         """Check if the user can run this command object."""
187
188         # has to be in the commands group
189         if command not in self.universe.groups["command"].values():
190             result = False
191
192         # avatars of administrators can run any command
193         elif self.owner and self.owner.account.get("administrator"):
194             result = True
195
196         # everyone can run non-administrative commands
197         elif not command.get("administrative"):
198             result = True
199
200         # otherwise the command cannot be run by this actor
201         else:
202             result = False
203
204         # pass back the result
205         return result
206
207     def update_location(self):
208         """Make sure the location's contents contain this element."""
209         area = self.get("location")
210         if area in self.universe.contents:
211             self.universe.contents[area].contents[self.key] = self
212
213     def clean_contents(self):
214         """Make sure the element's contents aren't bogus."""
215         for element in self.contents.values():
216             if element.get("location") != self.key:
217                 del self.contents[element.key]
218
219     def go_to(self, area):
220         """Relocate the element to a specific area."""
221         current = self.get("location")
222         if current and self.key in self.universe.contents[current].contents:
223             del universe.contents[current].contents[self.key]
224         if area in self.universe.contents:
225             self.set("location", area)
226         self.universe.contents[area].contents[self.key] = self
227         self.look_at(area)
228
229     def go_home(self):
230         """Relocate the element to its default location."""
231         self.go_to(self.get("default_location"))
232         self.echo_to_location(
233             "You suddenly realize that " + self.get("name") + " is here."
234         )
235
236     def move_direction(self, direction):
237         """Relocate the element in a specified direction."""
238         motion = self.universe.contents["mudpy.movement.%s" % direction]
239         enter_term = motion.get("enter_term")
240         exit_term = motion.get("exit_term")
241         self.echo_to_location("%s exits %s." % (self.get("name"), exit_term))
242         self.send("You exit %s." % exit_term, add_prompt=False)
243         self.go_to(
244             self.universe.contents[
245                 self.get("location")].link_neighbor(direction)
246         )
247         self.echo_to_location("%s arrives from %s." % (
248             self.get("name"), enter_term))
249
250     def look_at(self, key):
251         """Show an element to another element."""
252         if self.owner:
253             element = self.universe.contents[key]
254             message = ""
255             name = element.get("name")
256             if name:
257                 message += "$(cyn)" + name + "$(nrm)$(eol)"
258             description = element.get("description")
259             if description:
260                 message += description + "$(eol)"
261             portal_list = list(element.portals().keys())
262             if portal_list:
263                 portal_list.sort()
264                 message += "$(cyn)[ Exits: " + ", ".join(
265                     portal_list
266                 ) + " ]$(nrm)$(eol)"
267             for element in self.universe.contents[
268                 self.get("location")
269             ].contents.values():
270                 if element.get("is_actor") and element is not self:
271                     message += "$(yel)" + element.get(
272                         "name"
273                     ) + " is here.$(nrm)$(eol)"
274                 elif element is not self:
275                     message += "$(grn)" + element.get(
276                         "impression"
277                     ) + "$(nrm)$(eol)"
278             self.send(message)
279
280     def portals(self):
281         """Map the portal directions for an area to neighbors."""
282         portals = {}
283         if re.match(r"""^area\.-?\d+,-?\d+,-?\d+$""", self.key):
284             coordinates = [(int(x))
285                            for x in self.key.split(".")[-1].split(",")]
286             offsets = dict(
287                 (x,
288                  self.universe.contents["mudpy.movement.%s" % x].get("vector")
289                  ) for x in self.universe.directions)
290             for portal in self.get("gridlinks"):
291                 adjacent = map(lambda c, o: c + o,
292                                coordinates, offsets[portal])
293                 neighbor = "area." + ",".join(
294                     [(str(x)) for x in adjacent]
295                 )
296                 if neighbor in self.universe.contents:
297                     portals[portal] = neighbor
298         for facet in self.facets():
299             if facet.startswith("link_"):
300                 neighbor = self.get(facet)
301                 if neighbor in self.universe.contents:
302                     portal = facet.split("_")[1]
303                     portals[portal] = neighbor
304         return portals
305
306     def link_neighbor(self, direction):
307         """Return the element linked in a given direction."""
308         portals = self.portals()
309         if direction in portals:
310             return portals[direction]
311
312     def echo_to_location(self, message):
313         """Show a message to other elements in the current location."""
314         for element in self.universe.contents[
315             self.get("location")
316         ].contents.values():
317             if element is not self:
318                 element.send(message)
319
320
321 class Universe:
322
323     """The universe."""
324
325     def __init__(self, filename="", load=False):
326         """Initialize the universe."""
327         self.groups = {}
328         self.contents = {}
329         self.directions = set()
330         self.loading = False
331         self.loglines = []
332         self.origins = {}
333         self.reload_flag = False
334         self.setup_loglines = []
335         self.startdir = os.getcwd()
336         self.terminate_flag = False
337         self.userlist = []
338         if not filename:
339             possible_filenames = [
340                 "etc/mudpy.yaml",
341                 "/usr/local/mudpy/etc/mudpy.yaml",
342                 "/usr/local/etc/mudpy.yaml",
343                 "/etc/mudpy/mudpy.yaml",
344                 "/etc/mudpy.yaml"
345             ]
346             for filename in possible_filenames:
347                 if os.access(filename, os.R_OK):
348                     break
349         if not os.path.isabs(filename):
350             filename = os.path.join(self.startdir, filename)
351         self.filename = filename
352         if load:
353             # make sure to preserve any accumulated log entries during load
354             self.setup_loglines += self.load()
355
356     def load(self):
357         """Load universe data from persistent storage."""
358
359         # while loading, it's safe to update elements from read-only files
360         self.loading = True
361
362         # it's possible for this to enter before logging configuration is read
363         pending_loglines = []
364
365         # the files dict must exist and filename needs to be read-only
366         if not hasattr(
367            self, "files"
368            ) or not (
369             self.filename in self.files and self.files[
370                 self.filename
371             ].is_writeable()
372         ):
373
374             # clear out all read-only files
375             if hasattr(self, "files"):
376                 for data_filename in list(self.files.keys()):
377                     if not self.files[data_filename].is_writeable():
378                         del self.files[data_filename]
379
380             # start loading from the initial file
381             mudpy.data.Data(self.filename, self)
382
383         # load default storage locations for groups
384         if hasattr(self, "contents") and "mudpy.filing" in self.contents:
385             self.origins.update(self.contents["mudpy.filing"].get(
386                 "groups", {}))
387
388         # add some builtin groups we know we'll need
389         for group in ("account", "actor", "internal"):
390             self.add_group(group)
391
392         # make a list of inactive avatars
393         inactive_avatars = []
394         for account in self.groups.get("account", {}).values():
395             for avatar in account.get("avatars"):
396                 try:
397                     inactive_avatars.append(self.contents[avatar])
398                 except KeyError:
399                     pending_loglines.append((
400                         'Missing avatar "%s", possible data corruption' %
401                         avatar, 6))
402         for user in self.userlist:
403             if user.avatar in inactive_avatars:
404                 inactive_avatars.remove(user.avatar)
405
406         # go through all elements to clear out inactive avatar locations
407         for element in self.contents.values():
408             area = element.get("location")
409             if element in inactive_avatars and area:
410                 if area in self.contents and element.key in self.contents[
411                    area
412                    ].contents:
413                     del self.contents[area].contents[element.key]
414                 element.set("default_location", area)
415                 element.remove_facet("location")
416
417         # another pass to straighten out all the element contents
418         for element in self.contents.values():
419             element.update_location()
420             element.clean_contents()
421
422         # done loading, so disallow updating elements from read-only files
423         self.loading = False
424
425         return pending_loglines
426
427     def new(self):
428         """Create a new, empty Universe (the Big Bang)."""
429         new_universe = Universe()
430         for attribute in vars(self).keys():
431             exec("new_universe." + attribute + " = self." + attribute)
432         new_universe.reload_flag = False
433         del self
434         return new_universe
435
436     def save(self):
437         """Save the universe to persistent storage."""
438         for key in self.files:
439             self.files[key].save()
440
441     def initialize_server_socket(self):
442         """Create and open the listening socket."""
443
444         # need to know the local address and port number for the listener
445         host = self.contents["mudpy.network"].get("host")
446         port = self.contents["mudpy.network"].get("port")
447
448         # if no host was specified, bind to all local addresses (preferring
449         # ipv6)
450         if not host:
451             if socket.has_ipv6:
452                 host = "::"
453             else:
454                 host = "0.0.0.0"
455
456         # figure out if this is ipv4 or v6
457         family = socket.getaddrinfo(host, port)[0][0]
458         if family is socket.AF_INET6 and not socket.has_ipv6:
459             log("No support for IPv6 address %s (use IPv4 instead)." % host)
460
461         # create a new stream-type socket object
462         self.listening_socket = socket.socket(family, socket.SOCK_STREAM)
463
464         # set the socket options to allow existing open ones to be
465         # reused (fixes a bug where the server can't bind for a minute
466         # when restarting on linux systems)
467         self.listening_socket.setsockopt(
468             socket.SOL_SOCKET, socket.SO_REUSEADDR, 1
469         )
470
471         # bind the socket to to our desired server ipa and port
472         self.listening_socket.bind((host, port))
473
474         # disable blocking so we can proceed whether or not we can
475         # send/receive
476         self.listening_socket.setblocking(0)
477
478         # start listening on the socket
479         self.listening_socket.listen(1)
480
481         # note that we're now ready for user connections
482         log(
483             "Listening for Telnet connections on: " +
484             host + ":" + str(port)
485         )
486
487     def get_time(self):
488         """Convenience method to get the elapsed time counter."""
489         return self.groups["internal"]["counters"].get("elapsed")
490
491     def add_group(self, group, fallback=None):
492         """Set up group tracking/metadata."""
493         if group not in self.origins:
494             self.origins[group] = {}
495         if not fallback:
496             fallback = mudpy.data.find_file(
497                     ".".join((group, "yaml")), universe=self)
498         if "fallback" not in self.origins[group]:
499             self.origins[group]["fallback"] = fallback
500         flags = self.origins[group].get("flags", None)
501         if fallback not in self.files:
502             mudpy.data.Data(fallback, self, flags=flags)
503
504
505 class User:
506
507     """This is a connected user."""
508
509     def __init__(self):
510         """Default values for the in-memory user variables."""
511         self.account = None
512         self.address = ""
513         self.authenticated = False
514         self.avatar = None
515         self.columns = 79
516         self.connection = None
517         self.error = ""
518         self.input_queue = []
519         self.last_address = ""
520         self.last_input = universe.get_time()
521         self.menu_choices = {}
522         self.menu_seen = False
523         self.negotiation_pause = 0
524         self.output_queue = []
525         self.partial_input = b""
526         self.password_tries = 0
527         self.state = "initial"
528         self.telopts = {}
529
530     def quit(self):
531         """Log, close the connection and remove."""
532         if self.account:
533             name = self.account.get("name")
534         else:
535             name = ""
536         if name:
537             message = "User " + name
538         else:
539             message = "An unnamed user"
540         message += " logged out."
541         log(message, 2)
542         self.deactivate_avatar()
543         self.connection.close()
544         self.remove()
545
546     def check_idle(self):
547         """Warn or disconnect idle users as appropriate."""
548         idletime = universe.get_time() - self.last_input
549         linkdead_dict = universe.contents[
550             "mudpy.timing.idle.disconnect"].facets()
551         if self.state in linkdead_dict:
552             linkdead_state = self.state
553         else:
554             linkdead_state = "default"
555         if idletime > linkdead_dict[linkdead_state]:
556             self.send(
557                 "$(eol)$(red)You've done nothing for far too long... goodbye!"
558                 + "$(nrm)$(eol)",
559                 flush=True,
560                 add_prompt=False
561             )
562             logline = "Disconnecting "
563             if self.account and self.account.get("name"):
564                 logline += self.account.get("name")
565             else:
566                 logline += "an unknown user"
567             logline += (" after idling too long in the " + self.state
568                         + " state.")
569             log(logline, 2)
570             self.state = "disconnecting"
571             self.menu_seen = False
572         idle_dict = universe.contents["mudpy.timing.idle.warn"].facets()
573         if self.state in idle_dict:
574             idle_state = self.state
575         else:
576             idle_state = "default"
577         if idletime == idle_dict[idle_state]:
578             self.send(
579                 "$(eol)$(red)If you continue to be unproductive, "
580                 + "you'll be shown the door...$(nrm)$(eol)"
581             )
582
583     def reload(self):
584         """Save, load a new user and relocate the connection."""
585
586         # get out of the list
587         self.remove()
588
589         # create a new user object
590         new_user = User()
591
592         # set everything equivalent
593         for attribute in vars(self).keys():
594             exec("new_user." + attribute + " = self." + attribute)
595
596         # the avatar needs a new owner
597         if new_user.avatar:
598             new_user.avatar.owner = new_user
599
600         # add it to the list
601         universe.userlist.append(new_user)
602
603         # get rid of the old user object
604         del(self)
605
606     def replace_old_connections(self):
607         """Disconnect active users with the same name."""
608
609         # the default return value
610         return_value = False
611
612         # iterate over each user in the list
613         for old_user in universe.userlist:
614
615             # the name is the same but it's not us
616             if hasattr(
617                old_user, "account"
618                ) and old_user.account and old_user.account.get(
619                 "name"
620             ) == self.account.get(
621                 "name"
622             ) and old_user is not self:
623
624                 # make a note of it
625                 log(
626                     "User " + self.account.get(
627                         "name"
628                     ) + " reconnected--closing old connection to "
629                     + old_user.address + ".",
630                     2
631                 )
632                 old_user.send(
633                     "$(eol)$(red)New connection from " + self.address
634                     + ". Terminating old connection...$(nrm)$(eol)",
635                     flush=True,
636                     add_prompt=False
637                 )
638
639                 # close the old connection
640                 old_user.connection.close()
641
642                 # replace the old connection with this one
643                 old_user.send(
644                     "$(eol)$(red)Taking over old connection from "
645                     + old_user.address + ".$(nrm)"
646                 )
647                 old_user.connection = self.connection
648                 old_user.last_address = old_user.address
649                 old_user.address = self.address
650
651                 # take this one out of the list and delete
652                 self.remove()
653                 del(self)
654                 return_value = True
655                 break
656
657         # true if an old connection was replaced, false if not
658         return return_value
659
660     def authenticate(self):
661         """Flag the user as authenticated and disconnect duplicates."""
662         if self.state is not "authenticated":
663             self.authenticated = True
664             if ("mudpy.limit" in universe.contents and self.account.subkey in
665                     universe.contents["mudpy.limit"].get("admins")):
666                 self.account.set("administrator", True)
667                 log("Administrator %s authenticated." %
668                     self.account.get("name"), 2)
669             else:
670                 # log("User %s authenticated." % self.account.get("name"), 2)
671                 log("User %s authenticated." % self.account.subkey, 2)
672
673     def show_menu(self):
674         """Send the user their current menu."""
675         if not self.menu_seen:
676             self.menu_choices = get_menu_choices(self)
677             self.send(
678                 get_menu(self.state, self.error, self.menu_choices),
679                 "",
680                 add_terminator=True
681             )
682             self.menu_seen = True
683             self.error = False
684             self.adjust_echoing()
685
686     def adjust_echoing(self):
687         """Adjust echoing to match state menu requirements."""
688         if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_ECHO,
689                                    mudpy.telnet.US):
690             if menu_echo_on(self.state):
691                 mudpy.telnet.disable(self, mudpy.telnet.TELOPT_ECHO,
692                                      mudpy.telnet.US)
693         elif not menu_echo_on(self.state):
694             mudpy.telnet.enable(self, mudpy.telnet.TELOPT_ECHO,
695                                 mudpy.telnet.US)
696
697     def remove(self):
698         """Remove a user from the list of connected users."""
699         universe.userlist.remove(self)
700
701     def send(
702         self,
703         output,
704         eol="$(eol)",
705         raw=False,
706         flush=False,
707         add_prompt=True,
708         just_prompt=False,
709         add_terminator=False,
710         prepend_padding=True
711     ):
712         """Send arbitrary text to a connected user."""
713
714         # unless raw mode is on, clean it up all nice and pretty
715         if not raw:
716
717             # strip extra $(eol) off if present
718             while output.startswith("$(eol)"):
719                 output = output[6:]
720             while output.endswith("$(eol)"):
721                 output = output[:-6]
722             extra_lines = output.find("$(eol)$(eol)$(eol)")
723             while extra_lines > -1:
724                 output = output[:extra_lines] + output[extra_lines + 6:]
725                 extra_lines = output.find("$(eol)$(eol)$(eol)")
726
727             # start with a newline, append the message, then end
728             # with the optional eol string passed to this function
729             # and the ansi escape to return to normal text
730             if not just_prompt and prepend_padding:
731                 if (not self.output_queue or not
732                         self.output_queue[-1].endswith(b"\r\n")):
733                     output = "$(eol)" + output
734                 elif not self.output_queue[-1].endswith(
735                     b"\r\n\x1b[0m\r\n"
736                 ) and not self.output_queue[-1].endswith(
737                     b"\r\n\r\n"
738                 ):
739                     output = "$(eol)" + output
740             output += eol + chr(27) + "[0m"
741
742             # tack on a prompt if active
743             if self.state == "active":
744                 if not just_prompt:
745                     output += "$(eol)"
746                 if add_prompt:
747                     output += "> "
748                     mode = self.avatar.get("mode")
749                     if mode:
750                         output += "(" + mode + ") "
751
752             # find and replace macros in the output
753             output = replace_macros(self, output)
754
755             # wrap the text at the client's width (min 40, 0 disables)
756             if self.columns:
757                 if self.columns < 40:
758                     wrap = 40
759                 else:
760                     wrap = self.columns
761                 output = wrap_ansi_text(output, wrap)
762
763             # if supported by the client, encode it utf-8
764             if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_BINARY,
765                                        mudpy.telnet.US):
766                 encoded_output = output.encode("utf-8")
767
768             # otherwise just send ascii
769             else:
770                 encoded_output = output.encode("ascii", "replace")
771
772             # end with a terminator if requested
773             if add_prompt or add_terminator:
774                 if mudpy.telnet.is_enabled(
775                         self, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US):
776                     encoded_output += mudpy.telnet.telnet_proto(
777                         mudpy.telnet.IAC, mudpy.telnet.EOR)
778                 elif not mudpy.telnet.is_enabled(
779                         self, mudpy.telnet.TELOPT_SGA, mudpy.telnet.US):
780                     encoded_output += mudpy.telnet.telnet_proto(
781                         mudpy.telnet.IAC, mudpy.telnet.GA)
782
783             # and tack it onto the queue
784             self.output_queue.append(encoded_output)
785
786             # if this is urgent, flush all pending output
787             if flush:
788                 self.flush()
789
790         # just dump raw bytes as requested
791         else:
792             self.output_queue.append(output)
793             self.flush()
794
795     def pulse(self):
796         """All the things to do to the user per increment."""
797
798         # if the world is terminating, disconnect
799         if universe.terminate_flag:
800             self.state = "disconnecting"
801             self.menu_seen = False
802
803         # check for an idle connection and act appropriately
804         else:
805             self.check_idle()
806
807         # if output is paused, decrement the counter
808         if self.state == "initial":
809             if self.negotiation_pause:
810                 self.negotiation_pause -= 1
811             else:
812                 self.state = "entering_account_name"
813
814         # show the user a menu as needed
815         elif not self.state == "active":
816             self.show_menu()
817
818         # flush any pending output in the queue
819         self.flush()
820
821         # disconnect users with the appropriate state
822         if self.state == "disconnecting":
823             self.quit()
824
825         # check for input and add it to the queue
826         self.enqueue_input()
827
828         # there is input waiting in the queue
829         if self.input_queue:
830             handle_user_input(self)
831
832     def flush(self):
833         """Try to send the last item in the queue and remove it."""
834         if self.output_queue:
835             try:
836                 self.connection.send(self.output_queue[0])
837             except (BrokenPipeError, ConnectionResetError):
838                 if self.account and self.account.get("name"):
839                     account = self.account.get("name")
840                 else:
841                     account = "an unknown user"
842                 self.state = "disconnecting"
843                 log("Disconnected while sending to %s." % account, 7)
844             del self.output_queue[0]
845
846     def enqueue_input(self):
847         """Process and enqueue any new input."""
848
849         # check for some input
850         try:
851             raw_input = self.connection.recv(1024)
852         except (BlockingIOError, OSError):
853             raw_input = b""
854
855         # we got something
856         if raw_input:
857
858             # tack this on to any previous partial
859             self.partial_input += raw_input
860
861             # reply to and remove any IAC negotiation codes
862             mudpy.telnet.negotiate_telnet_options(self)
863
864             # separate multiple input lines
865             new_input_lines = self.partial_input.split(b"\n")
866
867             # if input doesn't end in a newline, replace the
868             # held partial input with the last line of it
869             if not self.partial_input.endswith(b"\n"):
870                 self.partial_input = new_input_lines.pop()
871
872             # otherwise, chop off the extra null input and reset
873             # the held partial input
874             else:
875                 new_input_lines.pop()
876                 self.partial_input = b""
877
878             # iterate over the remaining lines
879             for line in new_input_lines:
880
881                 # strip off extra whitespace
882                 line = line.strip()
883
884                 # log non-printable characters remaining
885                 if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_BINARY,
886                                            mudpy.telnet.HIM):
887                     asciiline = bytes([x for x in line if 32 <= x <= 126])
888                     if line != asciiline:
889                         logline = "Non-ASCII characters from "
890                         if self.account and self.account.get("name"):
891                             logline += self.account.get("name") + ": "
892                         else:
893                             logline += "unknown user: "
894                         logline += repr(line)
895                         log(logline, 4)
896                         line = asciiline
897
898                 try:
899                     line = line.decode("utf-8")
900                 except UnicodeDecodeError:
901                     logline = "Non-UTF-8 characters from "
902                     if self.account and self.account.get("name"):
903                         logline += self.account.get("name") + ": "
904                     else:
905                         logline += "unknown user: "
906                     logline += repr(line)
907                     log(logline, 4)
908                     return
909
910                 line = unicodedata.normalize("NFKC", line)
911
912                 # put on the end of the queue
913                 self.input_queue.append(line)
914
915     def new_avatar(self):
916         """Instantiate a new, unconfigured avatar for this user."""
917         counter = 0
918         while ("avatar_%s_%s" % (self.account.get("name"), counter)
919                 in universe.groups.get("actor", {}).keys()):
920             counter += 1
921         self.avatar = Element(
922             "actor.avatar_%s_%s" % (self.account.get("name"), counter),
923             universe)
924         self.avatar.append("inherit", "archetype.avatar")
925         self.account.append("avatars", self.avatar.key)
926
927     def delete_avatar(self, avatar):
928         """Remove an avatar from the world and from the user's list."""
929         if self.avatar is universe.contents[avatar]:
930             self.avatar = None
931         universe.contents[avatar].destroy()
932         avatars = self.account.get("avatars")
933         avatars.remove(avatar)
934         self.account.set("avatars", avatars)
935
936     def activate_avatar_by_index(self, index):
937         """Enter the world with a particular indexed avatar."""
938         self.avatar = universe.contents[
939             self.account.get("avatars")[index]]
940         self.avatar.owner = self
941         self.state = "active"
942         self.avatar.go_home()
943
944     def deactivate_avatar(self):
945         """Have the active avatar leave the world."""
946         if self.avatar:
947             current = self.avatar.get("location")
948             if current:
949                 self.avatar.set("default_location", current)
950                 self.avatar.echo_to_location(
951                     "You suddenly wonder where " + self.avatar.get(
952                         "name"
953                     ) + " went."
954                 )
955                 del universe.contents[current].contents[self.avatar.key]
956                 self.avatar.remove_facet("location")
957             self.avatar.owner = None
958             self.avatar = None
959
960     def destroy(self):
961         """Destroy the user and associated avatars."""
962         for avatar in self.account.get("avatars"):
963             self.delete_avatar(avatar)
964         self.account.destroy()
965
966     def list_avatar_names(self):
967         """List names of assigned avatars."""
968         avatars = []
969         for avatar in self.account.get("avatars"):
970             try:
971                 avatars.append(universe.contents[avatar].get("name"))
972             except KeyError:
973                 log('Missing avatar "%s", possible data corruption.' %
974                     avatar, 6)
975         return avatars
976
977
978 def broadcast(message, add_prompt=True):
979     """Send a message to all connected users."""
980     for each_user in universe.userlist:
981         each_user.send("$(eol)" + message, add_prompt=add_prompt)
982
983
984 def log(message, level=0):
985     """Log a message."""
986
987     # a couple references we need
988     if "mudpy.log" in universe.contents:
989         file_name = universe.contents["mudpy.log"].get("file", "")
990         max_log_lines = universe.contents["mudpy.log"].get("lines", 0)
991         syslog_name = universe.contents["mudpy.log"].get("syslog", "")
992     else:
993         file_name = ""
994         max_log_lines = 0
995         syslog_name = ""
996     timestamp = time.asctime()[4:19]
997
998     # turn the message into a list of nonempty lines
999     lines = [x for x in [(x.rstrip()) for x in message.split("\n")] if x != ""]
1000
1001     # send the timestamp and line to a file
1002     if file_name:
1003         if not os.path.isabs(file_name):
1004             file_name = os.path.join(universe.startdir, file_name)
1005         file_descriptor = codecs.open(file_name, "a", "utf-8")
1006         for line in lines:
1007             file_descriptor.write(timestamp + " " + line + "\n")
1008         file_descriptor.flush()
1009         file_descriptor.close()
1010
1011     # send the timestamp and line to standard output
1012     if ("mudpy.log" in universe.contents and
1013             universe.contents["mudpy.log"].get("stdout")):
1014         for line in lines:
1015             print(timestamp + " " + line)
1016
1017     # send the line to the system log
1018     if syslog_name:
1019         syslog.openlog(
1020             syslog_name.encode("utf-8"),
1021             syslog.LOG_PID,
1022             syslog.LOG_INFO | syslog.LOG_DAEMON
1023         )
1024         for line in lines:
1025             syslog.syslog(line)
1026         syslog.closelog()
1027
1028     # display to connected administrators
1029     for user in universe.userlist:
1030         if user.state == "active" and user.account.get(
1031            "administrator"
1032            ) and user.account.get("loglevel", 0) <= level:
1033             # iterate over every line in the message
1034             full_message = ""
1035             for line in lines:
1036                 full_message += (
1037                     "$(bld)$(red)" + timestamp + " "
1038                     + line.replace("$(", "$_(") + "$(nrm)$(eol)")
1039             user.send(full_message, flush=True)
1040
1041     # add to the recent log list
1042     for line in lines:
1043         while 0 < len(universe.loglines) >= max_log_lines:
1044             del universe.loglines[0]
1045         universe.loglines.append((level, timestamp + " " + line))
1046
1047
1048 def get_loglines(level, start, stop):
1049     """Return a specific range of loglines filtered by level."""
1050
1051     # filter the log lines
1052     loglines = [x for x in universe.loglines if x[0] >= level]
1053
1054     # we need these in several places
1055     total_count = str(len(universe.loglines))
1056     filtered_count = len(loglines)
1057
1058     # don't proceed if there are no lines
1059     if filtered_count:
1060
1061         # can't start before the begining or at the end
1062         if start > filtered_count:
1063             start = filtered_count
1064         if start < 1:
1065             start = 1
1066
1067         # can't stop before we start
1068         if stop > start:
1069             stop = start
1070         elif stop < 1:
1071             stop = 1
1072
1073         # some preamble
1074         message = "There are " + str(total_count)
1075         message += " log lines in memory and " + str(filtered_count)
1076         message += " at or above level " + str(level) + "."
1077         message += " The matching lines from " + str(stop) + " to "
1078         message += str(start) + " are:$(eol)$(eol)"
1079
1080         # add the text from the selected lines
1081         if stop > 1:
1082             range_lines = loglines[-start:-(stop - 1)]
1083         else:
1084             range_lines = loglines[-start:]
1085         for line in range_lines:
1086             message += "   (" + str(line[0]) + ") " + line[1].replace(
1087                 "$(", "$_("
1088             ) + "$(eol)"
1089
1090     # there were no lines
1091     else:
1092         message = "None of the " + str(total_count)
1093         message += " lines in memory matches your request."
1094
1095     # pass it back
1096     return message
1097
1098
1099 def glyph_columns(character):
1100     """Convenience function to return the column width of a glyph."""
1101     if unicodedata.east_asian_width(character) in "FW":
1102         return 2
1103     else:
1104         return 1
1105
1106
1107 def wrap_ansi_text(text, width):
1108     """Wrap text with arbitrary width while ignoring ANSI colors."""
1109
1110     # the current position in the entire text string, including all
1111     # characters, printable or otherwise
1112     abs_pos = 0
1113
1114     # the current text position relative to the begining of the line,
1115     # ignoring color escape sequences
1116     rel_pos = 0
1117
1118     # the absolute position of the most recent whitespace character
1119     last_whitespace = 0
1120
1121     # whether the current character is part of a color escape sequence
1122     escape = False
1123
1124     # normalize any potentially composited unicode before we count it
1125     text = unicodedata.normalize("NFKC", text)
1126
1127     # iterate over each character from the begining of the text
1128     for each_character in text:
1129
1130         # the current character is the escape character
1131         if each_character == "\x1b" and not escape:
1132             escape = True
1133
1134         # the current character is within an escape sequence
1135         elif escape:
1136
1137             # the current character is m, which terminates the
1138             # escape sequence
1139             if each_character == "m":
1140                 escape = False
1141
1142         # the current character is a newline, so reset the relative
1143         # position (start a new line)
1144         elif each_character == "\n":
1145             rel_pos = 0
1146             last_whitespace = abs_pos
1147
1148         # the current character meets the requested maximum line width,
1149         # so we need to backtrack and find a space at which to wrap;
1150         # special care is taken to avoid an off-by-one in case the
1151         # current character is a double-width glyph
1152         elif each_character != "\r" and (
1153             rel_pos >= width or (
1154                 rel_pos >= width - 1 and glyph_columns(
1155                     each_character
1156                 ) == 2
1157             )
1158         ):
1159
1160             # it's always possible we landed on whitespace
1161             if unicodedata.category(each_character) in ("Cc", "Zs"):
1162                 last_whitespace = abs_pos
1163
1164             # insert an eol in place of the space
1165             text = text[:last_whitespace] + "\r\n" + text[last_whitespace + 1:]
1166
1167             # increase the absolute position because an eol is two
1168             # characters but the space it replaced was only one
1169             abs_pos += 1
1170
1171             # now we're at the begining of a new line, plus the
1172             # number of characters wrapped from the previous line
1173             rel_pos = 0
1174             for remaining_characters in text[last_whitespace:abs_pos]:
1175                 rel_pos += glyph_columns(remaining_characters)
1176
1177         # as long as the character is not a carriage return and the
1178         # other above conditions haven't been met, count it as a
1179         # printable character
1180         elif each_character != "\r":
1181             rel_pos += glyph_columns(each_character)
1182             if unicodedata.category(each_character) in ("Cc", "Zs"):
1183                 last_whitespace = abs_pos
1184
1185         # increase the absolute position for every character
1186         abs_pos += 1
1187
1188     # return the newly-wrapped text
1189     return text
1190
1191
1192 def weighted_choice(data):
1193     """Takes a dict weighted by value and returns a random key."""
1194
1195     # this will hold our expanded list of keys from the data
1196     expanded = []
1197
1198     # create the expanded list of keys
1199     for key in data.keys():
1200         for count in range(data[key]):
1201             expanded.append(key)
1202
1203     # return one at random
1204     return random.choice(expanded)
1205
1206
1207 def random_name():
1208     """Returns a random character name."""
1209
1210     # the vowels and consonants needed to create romaji syllables
1211     vowels = [
1212         "a",
1213         "i",
1214         "u",
1215         "e",
1216         "o"
1217     ]
1218     consonants = [
1219         "'",
1220         "k",
1221         "z",
1222         "s",
1223         "sh",
1224         "z",
1225         "j",
1226         "t",
1227         "ch",
1228         "ts",
1229         "d",
1230         "n",
1231         "h",
1232         "f",
1233         "m",
1234         "y",
1235         "r",
1236         "w"
1237     ]
1238
1239     # this dict will hold our weighted list of syllables
1240     syllables = {}
1241
1242     # generate the list with an even weighting
1243     for consonant in consonants:
1244         for vowel in vowels:
1245             syllables[consonant + vowel] = 1
1246
1247     # we'll build the name into this string
1248     name = ""
1249
1250     # create a name of random length from the syllables
1251     for syllable in range(random.randrange(2, 6)):
1252         name += weighted_choice(syllables)
1253
1254     # strip any leading quotemark, capitalize and return the name
1255     return name.strip("'").capitalize()
1256
1257
1258 def replace_macros(user, text, is_input=False):
1259     """Replaces macros in text output."""
1260
1261     # third person pronouns
1262     pronouns = {
1263         "female": {"obj": "her", "pos": "hers", "sub": "she"},
1264         "male": {"obj": "him", "pos": "his", "sub": "he"},
1265         "neuter": {"obj": "it", "pos": "its", "sub": "it"}
1266     }
1267
1268     # a dict of replacement macros
1269     macros = {
1270         "eol": "\r\n",
1271         "bld": chr(27) + "[1m",
1272         "nrm": chr(27) + "[0m",
1273         "blk": chr(27) + "[30m",
1274         "blu": chr(27) + "[34m",
1275         "cyn": chr(27) + "[36m",
1276         "grn": chr(27) + "[32m",
1277         "mgt": chr(27) + "[35m",
1278         "red": chr(27) + "[31m",
1279         "yel": chr(27) + "[33m",
1280     }
1281
1282     # add dynamic macros where possible
1283     if user.account:
1284         account_name = user.account.get("name")
1285         if account_name:
1286             macros["account"] = account_name
1287     if user.avatar:
1288         avatar_gender = user.avatar.get("gender")
1289         if avatar_gender:
1290             macros["tpop"] = pronouns[avatar_gender]["obj"]
1291             macros["tppp"] = pronouns[avatar_gender]["pos"]
1292             macros["tpsp"] = pronouns[avatar_gender]["sub"]
1293
1294     # loop until broken
1295     while True:
1296
1297         # find and replace per the macros dict
1298         macro_start = text.find("$(")
1299         if macro_start == -1:
1300             break
1301         macro_end = text.find(")", macro_start) + 1
1302         macro = text[macro_start + 2:macro_end - 1]
1303         if macro in macros.keys():
1304             replacement = macros[macro]
1305
1306         # this is how we handle local file inclusion (dangerous!)
1307         elif macro.startswith("inc:"):
1308             incfile = mudpy.data.find_file(macro[4:], universe=universe)
1309             if os.path.exists(incfile):
1310                 incfd = codecs.open(incfile, "r", "utf-8")
1311                 replacement = ""
1312                 for line in incfd:
1313                     if line.endswith("\n") and not line.endswith("\r\n"):
1314                         line = line.replace("\n", "\r\n")
1315                     replacement += line
1316                 # lose the trailing eol
1317                 replacement = replacement[:-2]
1318             else:
1319                 replacement = ""
1320                 log("Couldn't read included " + incfile + " file.", 7)
1321
1322         # if we get here, log and replace it with null
1323         else:
1324             replacement = ""
1325             if not is_input:
1326                 log("Unexpected replacement macro " +
1327                     macro + " encountered.", 6)
1328
1329         # and now we act on the replacement
1330         text = text.replace("$(" + macro + ")", replacement)
1331
1332     # replace the look-like-a-macro sequence
1333     text = text.replace("$_(", "$(")
1334
1335     return text
1336
1337
1338 def escape_macros(value):
1339     """Escapes replacement macros in text."""
1340     if type(value) is str:
1341         return value.replace("$(", "$_(")
1342     else:
1343         return value
1344
1345
1346 def first_word(text, separator=" "):
1347     """Returns a tuple of the first word and the rest."""
1348     if text:
1349         if text.find(separator) > 0:
1350             return text.split(separator, 1)
1351         else:
1352             return text, ""
1353     else:
1354         return "", ""
1355
1356
1357 def on_pulse():
1358     """The things which should happen on each pulse, aside from reloads."""
1359
1360     # open the listening socket if it hasn't been already
1361     if not hasattr(universe, "listening_socket"):
1362         universe.initialize_server_socket()
1363
1364     # assign a user if a new connection is waiting
1365     user = check_for_connection(universe.listening_socket)
1366     if user:
1367         universe.userlist.append(user)
1368
1369     # iterate over the connected users
1370     for user in universe.userlist:
1371         user.pulse()
1372
1373     # add an element for counters if it doesn't exist
1374     if "counters" not in universe.groups.get("internal", {}):
1375         Element("internal.counters", universe)
1376
1377     # update the log every now and then
1378     if not universe.groups["internal"]["counters"].get("mark"):
1379         log(str(len(universe.userlist)) + " connection(s)")
1380         universe.groups["internal"]["counters"].set(
1381             "mark", universe.contents["mudpy.timing"].get("status")
1382         )
1383     else:
1384         universe.groups["internal"]["counters"].set(
1385             "mark", universe.groups["internal"]["counters"].get(
1386                 "mark"
1387             ) - 1
1388         )
1389
1390     # periodically save everything
1391     if not universe.groups["internal"]["counters"].get("save"):
1392         universe.save()
1393         universe.groups["internal"]["counters"].set(
1394             "save", universe.contents["mudpy.timing"].get("save")
1395         )
1396     else:
1397         universe.groups["internal"]["counters"].set(
1398             "save", universe.groups["internal"]["counters"].get(
1399                 "save"
1400             ) - 1
1401         )
1402
1403     # pause for a configurable amount of time (decimal seconds)
1404     time.sleep(universe.contents["mudpy.timing"].get("increment"))
1405
1406     # increase the elapsed increment counter
1407     universe.groups["internal"]["counters"].set(
1408         "elapsed", universe.groups["internal"]["counters"].get(
1409             "elapsed", 0
1410         ) + 1
1411     )
1412
1413
1414 def reload_data():
1415     """Reload all relevant objects."""
1416     for user in universe.userlist[:]:
1417         user.reload()
1418     for element in universe.contents.values():
1419         if element.origin.is_writeable():
1420             element.reload()
1421     universe.load()
1422
1423
1424 def check_for_connection(listening_socket):
1425     """Check for a waiting connection and return a new user object."""
1426
1427     # try to accept a new connection
1428     try:
1429         connection, address = listening_socket.accept()
1430     except BlockingIOError:
1431         return None
1432
1433     # note that we got one
1434     log("Connection from " + address[0], 2)
1435
1436     # disable blocking so we can proceed whether or not we can send/receive
1437     connection.setblocking(0)
1438
1439     # create a new user object
1440     user = User()
1441
1442     # associate this connection with it
1443     user.connection = connection
1444
1445     # set the user's ipa from the connection's ipa
1446     user.address = address[0]
1447
1448     # let the client know we WILL EOR (RFC 885)
1449     mudpy.telnet.enable(user, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US)
1450     user.negotiation_pause = 2
1451
1452     # return the new user object
1453     return user
1454
1455
1456 def get_menu(state, error=None, choices=None):
1457     """Show the correct menu text to a user."""
1458
1459     # make sure we don't reuse a mutable sequence by default
1460     if choices is None:
1461         choices = {}
1462
1463     # get the description or error text
1464     message = get_menu_description(state, error)
1465
1466     # get menu choices for the current state
1467     message += get_formatted_menu_choices(state, choices)
1468
1469     # try to get a prompt, if it was defined
1470     message += get_menu_prompt(state)
1471
1472     # throw in the default choice, if it exists
1473     message += get_formatted_default_menu_choice(state)
1474
1475     # display a message indicating if echo is off
1476     message += get_echo_message(state)
1477
1478     # return the assembly of various strings defined above
1479     return message
1480
1481
1482 def menu_echo_on(state):
1483     """True if echo is on, false if it is off."""
1484     return universe.groups["menu"][state].get("echo", True)
1485
1486
1487 def get_echo_message(state):
1488     """Return a message indicating that echo is off."""
1489     if menu_echo_on(state):
1490         return ""
1491     else:
1492         return "(won't echo) "
1493
1494
1495 def get_default_menu_choice(state):
1496     """Return the default choice for a menu."""
1497     return universe.groups["menu"][state].get("default")
1498
1499
1500 def get_formatted_default_menu_choice(state):
1501     """Default menu choice foratted for inclusion in a prompt string."""
1502     default_choice = get_default_menu_choice(state)
1503     if default_choice:
1504         return "[$(red)" + default_choice + "$(nrm)] "
1505     else:
1506         return ""
1507
1508
1509 def get_menu_description(state, error):
1510     """Get the description or error text."""
1511
1512     # an error condition was raised by the handler
1513     if error:
1514
1515         # try to get an error message matching the condition
1516         # and current state
1517         description = universe.groups[
1518             "menu"][state].get("error_" + error)
1519         if not description:
1520             description = "That is not a valid choice..."
1521         description = "$(red)" + description + "$(nrm)"
1522
1523     # there was no error condition
1524     else:
1525
1526         # try to get a menu description for the current state
1527         description = universe.groups["menu"][state].get("description")
1528
1529     # return the description or error message
1530     if description:
1531         description += "$(eol)$(eol)"
1532     return description
1533
1534
1535 def get_menu_prompt(state):
1536     """Try to get a prompt, if it was defined."""
1537     prompt = universe.groups["menu"][state].get("prompt")
1538     if prompt:
1539         prompt += " "
1540     return prompt
1541
1542
1543 def get_menu_choices(user):
1544     """Return a dict of choice:meaning."""
1545     menu = universe.groups["menu"][user.state]
1546     create_choices = menu.get("create")
1547     if create_choices:
1548         choices = eval(create_choices)
1549     else:
1550         choices = {}
1551     ignores = []
1552     options = {}
1553     creates = {}
1554     for facet in menu.facets():
1555         if facet.startswith("demand_") and not eval(
1556            universe.groups["menu"][user.state].get(facet)
1557            ):
1558             ignores.append(facet.split("_", 2)[1])
1559         elif facet.startswith("create_"):
1560             creates[facet] = facet.split("_", 2)[1]
1561         elif facet.startswith("choice_"):
1562             options[facet] = facet.split("_", 2)[1]
1563     for facet in creates.keys():
1564         if not creates[facet] in ignores:
1565             choices[creates[facet]] = eval(menu.get(facet))
1566     for facet in options.keys():
1567         if not options[facet] in ignores:
1568             choices[options[facet]] = menu.get(facet)
1569     return choices
1570
1571
1572 def get_formatted_menu_choices(state, choices):
1573     """Returns a formatted string of menu choices."""
1574     choice_output = ""
1575     choice_keys = list(choices.keys())
1576     choice_keys.sort()
1577     for choice in choice_keys:
1578         choice_output += "   [$(red)" + choice + "$(nrm)]  " + choices[
1579             choice
1580         ] + "$(eol)"
1581     if choice_output:
1582         choice_output += "$(eol)"
1583     return choice_output
1584
1585
1586 def get_menu_branches(state):
1587     """Return a dict of choice:branch."""
1588     branches = {}
1589     for facet in universe.groups["menu"][state].facets():
1590         if facet.startswith("branch_"):
1591             branches[
1592                 facet.split("_", 2)[1]
1593             ] = universe.groups["menu"][state].get(facet)
1594     return branches
1595
1596
1597 def get_default_branch(state):
1598     """Return the default branch."""
1599     return universe.groups["menu"][state].get("branch")
1600
1601
1602 def get_choice_branch(user, choice):
1603     """Returns the new state matching the given choice."""
1604     branches = get_menu_branches(user.state)
1605     if choice in branches.keys():
1606         return branches[choice]
1607     elif choice in user.menu_choices.keys():
1608         return get_default_branch(user.state)
1609     else:
1610         return ""
1611
1612
1613 def get_menu_actions(state):
1614     """Return a dict of choice:branch."""
1615     actions = {}
1616     for facet in universe.groups["menu"][state].facets():
1617         if facet.startswith("action_"):
1618             actions[
1619                 facet.split("_", 2)[1]
1620             ] = universe.groups["menu"][state].get(facet)
1621     return actions
1622
1623
1624 def get_default_action(state):
1625     """Return the default action."""
1626     return universe.groups["menu"][state].get("action")
1627
1628
1629 def get_choice_action(user, choice):
1630     """Run any indicated script for the given choice."""
1631     actions = get_menu_actions(user.state)
1632     if choice in actions.keys():
1633         return actions[choice]
1634     elif choice in user.menu_choices.keys():
1635         return get_default_action(user.state)
1636     else:
1637         return ""
1638
1639
1640 def handle_user_input(user):
1641     """The main handler, branches to a state-specific handler."""
1642
1643     # if the user's client echo is off, send a blank line for aesthetics
1644     if mudpy.telnet.is_enabled(user, mudpy.telnet.TELOPT_ECHO,
1645                                mudpy.telnet.US):
1646         user.send("", add_prompt=False, prepend_padding=False)
1647
1648     # check to make sure the state is expected, then call that handler
1649     if "handler_" + user.state in globals():
1650         exec("handler_" + user.state + "(user)")
1651     else:
1652         generic_menu_handler(user)
1653
1654     # since we got input, flag that the menu/prompt needs to be redisplayed
1655     user.menu_seen = False
1656
1657     # update the last_input timestamp while we're at it
1658     user.last_input = universe.get_time()
1659
1660
1661 def generic_menu_handler(user):
1662     """A generic menu choice handler."""
1663
1664     # get a lower-case representation of the next line of input
1665     if user.input_queue:
1666         choice = user.input_queue.pop(0)
1667         if choice:
1668             choice = choice.lower()
1669     else:
1670         choice = ""
1671     if not choice:
1672         choice = get_default_menu_choice(user.state)
1673     if choice in user.menu_choices:
1674         exec(get_choice_action(user, choice))
1675         new_state = get_choice_branch(user, choice)
1676         if new_state:
1677             user.state = new_state
1678     else:
1679         user.error = "default"
1680
1681
1682 def handler_entering_account_name(user):
1683     """Handle the login account name."""
1684
1685     # get the next waiting line of input
1686     input_data = user.input_queue.pop(0)
1687
1688     # did the user enter anything?
1689     if input_data:
1690
1691         # keep only the first word and convert to lower-case
1692         name = input_data.lower()
1693
1694         # fail if there are non-alphanumeric characters
1695         if name != "".join(filter(
1696                 lambda x: x >= "0" and x <= "9" or x >= "a" and x <= "z",
1697                 name)):
1698             user.error = "bad_name"
1699
1700         # if that account exists, time to request a password
1701         elif name in universe.groups.get("account", {}):
1702             user.account = universe.groups["account"][name]
1703             user.state = "checking_password"
1704
1705         # otherwise, this could be a brand new user
1706         else:
1707             user.account = Element("account.%s" % name, universe)
1708             user.account.set("name", name)
1709             log("New user: " + name, 2)
1710             user.state = "checking_new_account_name"
1711
1712     # if the user entered nothing for a name, then buhbye
1713     else:
1714         user.state = "disconnecting"
1715
1716
1717 def handler_checking_password(user):
1718     """Handle the login account password."""
1719
1720     # get the next waiting line of input
1721     input_data = user.input_queue.pop(0)
1722
1723     if "mudpy.limit" in universe.contents:
1724         max_password_tries = universe.contents["mudpy.limit"].get(
1725             "password_tries", 3)
1726     else:
1727         max_password_tries = 3
1728
1729     # does the hashed input equal the stored hash?
1730     if mudpy.password.verify(input_data, user.account.get("passhash")):
1731
1732         # if so, set the username and load from cold storage
1733         if not user.replace_old_connections():
1734             user.authenticate()
1735             user.state = "main_utility"
1736
1737     # if at first your hashes don't match, try, try again
1738     elif user.password_tries < max_password_tries - 1:
1739         user.password_tries += 1
1740         user.error = "incorrect"
1741
1742     # we've exceeded the maximum number of password failures, so disconnect
1743     else:
1744         user.send(
1745             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1746         )
1747         user.state = "disconnecting"
1748
1749
1750 def handler_entering_new_password(user):
1751     """Handle a new password entry."""
1752
1753     # get the next waiting line of input
1754     input_data = user.input_queue.pop(0)
1755
1756     if "mudpy.limit" in universe.contents:
1757         max_password_tries = universe.contents["mudpy.limit"].get(
1758             "password_tries", 3)
1759     else:
1760         max_password_tries = 3
1761
1762     # make sure the password is strong--at least one upper, one lower and
1763     # one digit, seven or more characters in length
1764     if len(input_data) > 6 and len(
1765        list(filter(lambda x: x >= "0" and x <= "9", input_data))
1766        ) and len(
1767         list(filter(lambda x: x >= "A" and x <= "Z", input_data))
1768     ) and len(
1769         list(filter(lambda x: x >= "a" and x <= "z", input_data))
1770     ):
1771
1772         # hash and store it, then move on to verification
1773         user.account.set("passhash", mudpy.password.create(input_data))
1774         user.state = "verifying_new_password"
1775
1776     # the password was weak, try again if you haven't tried too many times
1777     elif user.password_tries < max_password_tries - 1:
1778         user.password_tries += 1
1779         user.error = "weak"
1780
1781     # too many tries, so adios
1782     else:
1783         user.send(
1784             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1785         )
1786         user.account.destroy()
1787         user.state = "disconnecting"
1788
1789
1790 def handler_verifying_new_password(user):
1791     """Handle the re-entered new password for verification."""
1792
1793     # get the next waiting line of input
1794     input_data = user.input_queue.pop(0)
1795
1796     if "mudpy.limit" in universe.contents:
1797         max_password_tries = universe.contents["mudpy.limit"].get(
1798             "password_tries", 3)
1799     else:
1800         max_password_tries = 3
1801
1802     # hash the input and match it to storage
1803     if mudpy.password.verify(input_data, user.account.get("passhash")):
1804         user.authenticate()
1805
1806         # the hashes matched, so go active
1807         if not user.replace_old_connections():
1808             user.state = "main_utility"
1809
1810     # go back to entering the new password as long as you haven't tried
1811     # too many times
1812     elif user.password_tries < max_password_tries - 1:
1813         user.password_tries += 1
1814         user.error = "differs"
1815         user.state = "entering_new_password"
1816
1817     # otherwise, sayonara
1818     else:
1819         user.send(
1820             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1821         )
1822         user.account.destroy()
1823         user.state = "disconnecting"
1824
1825
1826 def handler_active(user):
1827     """Handle input for active users."""
1828
1829     # get the next waiting line of input
1830     input_data = user.input_queue.pop(0)
1831
1832     # is there input?
1833     if input_data:
1834
1835         # split out the command and parameters
1836         actor = user.avatar
1837         mode = actor.get("mode")
1838         if mode and input_data.startswith("!"):
1839             command_name, parameters = first_word(input_data[1:])
1840         elif mode == "chat":
1841             command_name = "say"
1842             parameters = input_data
1843         else:
1844             command_name, parameters = first_word(input_data)
1845
1846         # lowercase the command
1847         command_name = command_name.lower()
1848
1849         # the command matches a command word for which we have data
1850         if command_name in universe.groups["command"]:
1851             command = universe.groups["command"][command_name]
1852         else:
1853             command = None
1854
1855         # if it's allowed, do it
1856         if actor.can_run(command):
1857             exec(command.get("action"))
1858
1859         # otherwise, give an error
1860         elif command_name:
1861             command_error(actor, input_data)
1862
1863     # if no input, just idle back with a prompt
1864     else:
1865         user.send("", just_prompt=True)
1866
1867
1868 def command_halt(actor, parameters):
1869     """Halt the world."""
1870     if actor.owner:
1871
1872         # see if there's a message or use a generic one
1873         if parameters:
1874             message = "Halting: " + parameters
1875         else:
1876             message = "User " + actor.owner.account.get(
1877                 "name"
1878             ) + " halted the world."
1879
1880         # let everyone know
1881         broadcast(message, add_prompt=False)
1882         log(message, 8)
1883
1884         # set a flag to terminate the world
1885         universe.terminate_flag = True
1886
1887
1888 def command_reload(actor):
1889     """Reload all code modules, configs and data."""
1890     if actor.owner:
1891
1892         # let the user know and log
1893         actor.send("Reloading all code modules, configs and data.")
1894         log(
1895             "User " +
1896             actor.owner.account.get("name") + " reloaded the world.",
1897             6
1898         )
1899
1900         # set a flag to reload
1901         universe.reload_flag = True
1902
1903
1904 def command_quit(actor):
1905     """Leave the world and go back to the main menu."""
1906     if actor.owner:
1907         actor.owner.state = "main_utility"
1908         actor.owner.deactivate_avatar()
1909
1910
1911 def command_help(actor, parameters):
1912     """List available commands and provide help for commands."""
1913
1914     # did the user ask for help on a specific command word?
1915     if parameters and actor.owner:
1916
1917         # is the command word one for which we have data?
1918         if parameters in universe.groups["command"]:
1919             command = universe.groups["command"][parameters]
1920         else:
1921             command = None
1922
1923         # only for allowed commands
1924         if actor.can_run(command):
1925
1926             # add a description if provided
1927             description = command.get("description")
1928             if not description:
1929                 description = "(no short description provided)"
1930             if command.get("administrative"):
1931                 output = "$(red)"
1932             else:
1933                 output = "$(grn)"
1934             output += parameters + "$(nrm) - " + description + "$(eol)$(eol)"
1935
1936             # add the help text if provided
1937             help_text = command.get("help")
1938             if not help_text:
1939                 help_text = "No help is provided for this command."
1940             output += help_text
1941
1942             # list related commands
1943             see_also = command.get("see_also")
1944             if see_also:
1945                 really_see_also = ""
1946                 for item in see_also:
1947                     if item in universe.groups["command"]:
1948                         command = universe.groups["command"][item]
1949                         if actor.can_run(command):
1950                             if really_see_also:
1951                                 really_see_also += ", "
1952                             if command.get("administrative"):
1953                                 really_see_also += "$(red)"
1954                             else:
1955                                 really_see_also += "$(grn)"
1956                             really_see_also += item + "$(nrm)"
1957                 if really_see_also:
1958                     output += "$(eol)$(eol)See also: " + really_see_also
1959
1960         # no data for the requested command word
1961         else:
1962             output = "That is not an available command."
1963
1964     # no specific command word was indicated
1965     else:
1966
1967         # give a sorted list of commands with descriptions if provided
1968         output = "These are the commands available to you:$(eol)$(eol)"
1969         sorted_commands = list(universe.groups["command"].keys())
1970         sorted_commands.sort()
1971         for item in sorted_commands:
1972             command = universe.groups["command"][item]
1973             if actor.can_run(command):
1974                 description = command.get("description")
1975                 if not description:
1976                     description = "(no short description provided)"
1977                 if command.get("administrative"):
1978                     output += "   $(red)"
1979                 else:
1980                     output += "   $(grn)"
1981                 output += item + "$(nrm) - " + description + "$(eol)"
1982         output += ('$(eol)Enter "help COMMAND" for help on a command '
1983                    'named "COMMAND".')
1984
1985     # send the accumulated output to the user
1986     actor.send(output)
1987
1988
1989 def command_move(actor, parameters):
1990     """Move the avatar in a given direction."""
1991     if parameters in universe.contents[actor.get("location")].portals():
1992         actor.move_direction(parameters)
1993     else:
1994         actor.send("You cannot go that way.")
1995
1996
1997 def command_look(actor, parameters):
1998     """Look around."""
1999     if parameters:
2000         actor.send("You can't look at or in anything yet.")
2001     else:
2002         actor.look_at(actor.get("location"))
2003
2004
2005 def command_say(actor, parameters):
2006     """Speak to others in the same area."""
2007
2008     # check for replacement macros and escape them
2009     parameters = escape_macros(parameters)
2010
2011     # if the message is wrapped in quotes, remove them and leave contents
2012     # intact
2013     if parameters.startswith('"') and parameters.endswith('"'):
2014         message = parameters[1:-1]
2015         literal = True
2016
2017     # otherwise, get rid of stray quote marks on the ends of the message
2018     else:
2019         message = parameters.strip('''"'`''')
2020         literal = False
2021
2022     # the user entered a message
2023     if message:
2024
2025         # match the punctuation used, if any, to an action
2026         if "mudpy.linguistic" in universe.contents:
2027             actions = universe.contents["mudpy.linguistic"].get("actions", {})
2028             default_punctuation = (universe.contents["mudpy.linguistic"].get(
2029                 "default_punctuation", "."))
2030         else:
2031             actions = {}
2032             default_punctuation = "."
2033         action = ""
2034
2035         # reverse sort punctuation options so the longest match wins
2036         for mark in sorted(actions.keys(), reverse=True):
2037             if not literal and message.endswith(mark):
2038                 action = actions[mark]
2039                 break
2040
2041         # add punctuation if needed
2042         if not action:
2043             action = actions[default_punctuation]
2044             if message and not (
2045                literal or unicodedata.category(message[-1]) == "Po"
2046                ):
2047                 message += default_punctuation
2048
2049         # failsafe checks to avoid unwanted reformatting and null strings
2050         if message and not literal:
2051
2052             # decapitalize the first letter to improve matching
2053             message = message[0].lower() + message[1:]
2054
2055             # iterate over all words in message, replacing typos
2056             if "mudpy.linguistic" in universe.contents:
2057                 typos = universe.contents["mudpy.linguistic"].get("typos", {})
2058             else:
2059                 typos = {}
2060             words = message.split()
2061             for index in range(len(words)):
2062                 word = words[index]
2063                 while unicodedata.category(word[0]) == "Po":
2064                     word = word[1:]
2065                 while unicodedata.category(word[-1]) == "Po":
2066                     word = word[:-1]
2067                 if word in typos.keys():
2068                     words[index] = words[index].replace(word, typos[word])
2069             message = " ".join(words)
2070
2071             # capitalize the first letter
2072             message = message[0].upper() + message[1:]
2073
2074     # tell the area
2075     if message:
2076         actor.echo_to_location(
2077             actor.get("name") + " " + action + 's, "' + message + '"'
2078         )
2079         actor.send("You " + action + ', "' + message + '"')
2080
2081     # there was no message
2082     else:
2083         actor.send("What do you want to say?")
2084
2085
2086 def command_chat(actor):
2087     """Toggle chat mode."""
2088     mode = actor.get("mode")
2089     if not mode:
2090         actor.set("mode", "chat")
2091         actor.send("Entering chat mode (use $(grn)!chat$(nrm) to exit).")
2092     elif mode == "chat":
2093         actor.remove_facet("mode")
2094         actor.send("Exiting chat mode.")
2095     else:
2096         actor.send("Sorry, but you're already busy with something else!")
2097
2098
2099 def command_show(actor, parameters):
2100     """Show program data."""
2101     message = ""
2102     arguments = parameters.split()
2103     if not parameters:
2104         message = "What do you want to show?"
2105     elif arguments[0] == "time":
2106         message = universe.groups["internal"]["counters"].get(
2107             "elapsed"
2108         ) + " increments elapsed since the world was created."
2109     elif arguments[0] == "groups":
2110         message = "These are the element groups:$(eol)"
2111         groups = list(universe.groups.keys())
2112         groups.sort()
2113         for group in groups:
2114             message += "$(eol)   $(grn)" + group + "$(nrm)"
2115     elif arguments[0] == "files":
2116         message = "These are the current files containing the universe:$(eol)"
2117         filenames = sorted(universe.files)
2118         for filename in filenames:
2119             if universe.files[filename].is_writeable():
2120                 status = "rw"
2121             else:
2122                 status = "ro"
2123             message += ("$(eol)   $(red)(%s) $(grn)%s$(nrm)" %
2124                         (status, filename))
2125             if universe.files[filename].flags:
2126                 message += (" $(yel)[%s]$(nrm)" %
2127                             ",".join(universe.files[filename].flags))
2128     elif arguments[0] == "group":
2129         if len(arguments) != 2:
2130             message = "You must specify one group."
2131         elif arguments[1] in universe.groups:
2132             message = ('These are the elements in the "' + arguments[1]
2133                        + '" group:$(eol)')
2134             elements = [
2135                 (
2136                     universe.groups[arguments[1]][x].key
2137                 ) for x in universe.groups[arguments[1]].keys()
2138             ]
2139             elements.sort()
2140             for element in elements:
2141                 message += "$(eol)   $(grn)" + element + "$(nrm)"
2142         else:
2143             message = 'Group "' + arguments[1] + '" does not exist.'
2144     elif arguments[0] == "file":
2145         if len(arguments) != 2:
2146             message = "You must specify one file."
2147         elif arguments[1] in universe.files:
2148             message = ('These are the nodes in the "' + arguments[1]
2149                        + '" file:$(eol)')
2150             elements = sorted(universe.files[arguments[1]].data)
2151             for element in elements:
2152                 message += "$(eol)   $(grn)" + element + "$(nrm)"
2153         else:
2154             message = 'File "%s" does not exist.' % arguments[1]
2155     elif arguments[0] == "element":
2156         if len(arguments) != 2:
2157             message = "You must specify one element."
2158         elif arguments[1].strip(".") in universe.contents:
2159             element = universe.contents[arguments[1].strip(".")]
2160             message = ('These are the properties of the "' + arguments[1]
2161                        + '" element (in "' + element.origin.source
2162                        + '"):$(eol)')
2163             facets = element.facets()
2164             for facet in sorted(facets):
2165                 message += ("$(eol)   $(grn)%s: $(red)%s$(nrm)" %
2166                             (facet, str(facets[facet])))
2167         else:
2168             message = 'Element "' + arguments[1] + '" does not exist.'
2169     elif arguments[0] == "result":
2170         if len(arguments) < 2:
2171             message = "You need to specify an expression."
2172         else:
2173             try:
2174                 message = repr(eval(" ".join(arguments[1:])))
2175             except Exception as e:
2176                 message = ("$(red)Your expression raised an exception...$(eol)"
2177                            "$(eol)$(bld)%s$(nrm)" % e)
2178     elif arguments[0] == "log":
2179         if len(arguments) == 4:
2180             if re.match(r"^\d+$", arguments[3]) and int(arguments[3]) >= 0:
2181                 stop = int(arguments[3])
2182             else:
2183                 stop = -1
2184         else:
2185             stop = 0
2186         if len(arguments) >= 3:
2187             if re.match(r"^\d+$", arguments[2]) and int(arguments[2]) > 0:
2188                 start = int(arguments[2])
2189             else:
2190                 start = -1
2191         else:
2192             start = 10
2193         if len(arguments) >= 2:
2194             if (re.match(r"^\d+$", arguments[1])
2195                     and 0 <= int(arguments[1]) <= 9):
2196                 level = int(arguments[1])
2197             else:
2198                 level = -1
2199         elif 0 <= actor.owner.account.get("loglevel", 0) <= 9:
2200             level = actor.owner.account.get("loglevel", 0)
2201         else:
2202             level = 1
2203         if level > -1 and start > -1 and stop > -1:
2204             message = get_loglines(level, start, stop)
2205         else:
2206             message = ("When specified, level must be 0-9 (default 1), "
2207                        "start and stop must be >=1 (default 10 and 1).")
2208     else:
2209         message = '''I don't know what "''' + parameters + '" is.'
2210     actor.send(message)
2211
2212
2213 def command_create(actor, parameters):
2214     """Create an element if it does not exist."""
2215     if not parameters:
2216         message = "You must at least specify an element to create."
2217     elif not actor.owner:
2218         message = ""
2219     else:
2220         arguments = parameters.split()
2221         if len(arguments) == 1:
2222             arguments.append("")
2223         if len(arguments) == 2:
2224             element, filename = arguments
2225             if element in universe.contents:
2226                 message = 'The "' + element + '" element already exists.'
2227             else:
2228                 message = ('You create "' +
2229                            element + '" within the universe.')
2230                 logline = actor.owner.account.get(
2231                     "name"
2232                 ) + " created an element: " + element
2233                 if filename:
2234                     logline += " in file " + filename
2235                     if filename not in universe.files:
2236                         message += (
2237                             ' Warning: "' + filename + '" is not yet '
2238                             "included in any other file and will not be read "
2239                             "on startup unless this is remedied.")
2240                 Element(element, universe, filename)
2241                 log(logline, 6)
2242         elif len(arguments) > 2:
2243             message = "You can only specify an element and a filename."
2244     actor.send(message)
2245
2246
2247 def command_destroy(actor, parameters):
2248     """Destroy an element if it exists."""
2249     if actor.owner:
2250         if not parameters:
2251             message = "You must specify an element to destroy."
2252         else:
2253             if parameters not in universe.contents:
2254                 message = 'The "' + parameters + '" element does not exist.'
2255             else:
2256                 universe.contents[parameters].destroy()
2257                 message = ('You destroy "' + parameters
2258                            + '" within the universe.')
2259                 log(
2260                     actor.owner.account.get(
2261                         "name"
2262                     ) + " destroyed an element: " + parameters,
2263                     6
2264                 )
2265         actor.send(message)
2266
2267
2268 def command_set(actor, parameters):
2269     """Set a facet of an element."""
2270     if not parameters:
2271         message = "You must specify an element, a facet and a value."
2272     else:
2273         arguments = parameters.split(" ", 2)
2274         if len(arguments) == 1:
2275             message = ('What facet of element "' + arguments[0]
2276                        + '" would you like to set?')
2277         elif len(arguments) == 2:
2278             message = ('What value would you like to set for the "' +
2279                        arguments[1] + '" facet of the "' + arguments[0]
2280                        + '" element?')
2281         else:
2282             element, facet, value = arguments
2283             if element not in universe.contents:
2284                 message = 'The "' + element + '" element does not exist.'
2285             else:
2286                 try:
2287                     universe.contents[element].set(facet, value)
2288                 except PermissionError:
2289                     message = ('The "%s" element is kept in read-only file '
2290                                '"%s" and cannot be altered.' %
2291                                (element, universe.contents[
2292                                         element].origin.source))
2293                 except ValueError:
2294                     message = ('Value "%s" of type "%s" cannot be coerced '
2295                                'to the correct datatype for facet "%s".' %
2296                                (value, type(value), facet))
2297                 else:
2298                     message = ('You have successfully (re)set the "' + facet
2299                                + '" facet of element "' + element
2300                                + '". Try "show element ' +
2301                                element + '" for verification.')
2302     actor.send(message)
2303
2304
2305 def command_delete(actor, parameters):
2306     """Delete a facet from an element."""
2307     if not parameters:
2308         message = "You must specify an element and a facet."
2309     else:
2310         arguments = parameters.split(" ")
2311         if len(arguments) == 1:
2312             message = ('What facet of element "' + arguments[0]
2313                        + '" would you like to delete?')
2314         elif len(arguments) != 2:
2315             message = "You may only specify an element and a facet."
2316         else:
2317             element, facet = arguments
2318             if element not in universe.contents:
2319                 message = 'The "' + element + '" element does not exist.'
2320             elif facet not in universe.contents[element].facets():
2321                 message = ('The "' + element + '" element has no "' + facet
2322                            + '" facet.')
2323             else:
2324                 universe.contents[element].remove_facet(facet)
2325                 message = ('You have successfully deleted the "' + facet
2326                            + '" facet of element "' + element
2327                            + '". Try "show element ' +
2328                            element + '" for verification.')
2329     actor.send(message)
2330
2331
2332 def command_error(actor, input_data):
2333     """Generic error for an unrecognized command word."""
2334
2335     # 90% of the time use a generic error
2336     if random.randrange(10):
2337         message = '''I'm not sure what "''' + input_data + '''" means...'''
2338
2339     # 10% of the time use the classic diku error
2340     else:
2341         message = "Arglebargle, glop-glyf!?!"
2342
2343     # send the error message
2344     actor.send(message)
2345
2346
2347 def daemonize(universe):
2348     """Fork and disassociate from everything."""
2349
2350     # only if this is what we're configured to do
2351     if "mudpy.process" in universe.contents and universe.contents[
2352             "mudpy.process"].get("daemon"):
2353
2354         # log before we start forking around, so the terminal gets the message
2355         log("Disassociating from the controlling terminal.")
2356
2357         # fork off and die, so we free up the controlling terminal
2358         if os.fork():
2359             os._exit(0)
2360
2361         # switch to a new process group
2362         os.setsid()
2363
2364         # fork some more, this time to free us from the old process group
2365         if os.fork():
2366             os._exit(0)
2367
2368         # reset the working directory so we don't needlessly tie up mounts
2369         os.chdir("/")
2370
2371         # clear the file creation mask so we can bend it to our will later
2372         os.umask(0)
2373
2374         # redirect stdin/stdout/stderr and close off their former descriptors
2375         for stdpipe in range(3):
2376             os.close(stdpipe)
2377         sys.stdin = codecs.open("/dev/null", "r", "utf-8")
2378         sys.__stdin__ = codecs.open("/dev/null", "r", "utf-8")
2379         sys.stdout = codecs.open("/dev/null", "w", "utf-8")
2380         sys.stderr = codecs.open("/dev/null", "w", "utf-8")
2381         sys.__stdout__ = codecs.open("/dev/null", "w", "utf-8")
2382         sys.__stderr__ = codecs.open("/dev/null", "w", "utf-8")
2383
2384
2385 def create_pidfile(universe):
2386     """Write a file containing the current process ID."""
2387     pid = str(os.getpid())
2388     log("Process ID: " + pid)
2389     if "mudpy.process" in universe.contents:
2390         file_name = universe.contents["mudpy.process"].get("pidfile", "")
2391     else:
2392         file_name = ""
2393     if file_name:
2394         if not os.path.isabs(file_name):
2395             file_name = os.path.join(universe.startdir, file_name)
2396         file_descriptor = codecs.open(file_name, "w", "utf-8")
2397         file_descriptor.write(pid + "\n")
2398         file_descriptor.flush()
2399         file_descriptor.close()
2400
2401
2402 def remove_pidfile(universe):
2403     """Remove the file containing the current process ID."""
2404     if "mudpy.process" in universe.contents:
2405         file_name = universe.contents["mudpy.process"].get("pidfile", "")
2406     else:
2407         file_name = ""
2408     if file_name:
2409         if not os.path.isabs(file_name):
2410             file_name = os.path.join(universe.startdir, file_name)
2411         if os.access(file_name, os.W_OK):
2412             os.remove(file_name)
2413
2414
2415 def excepthook(excepttype, value, tracebackdata):
2416     """Handle uncaught exceptions."""
2417
2418     # assemble the list of errors into a single string
2419     message = "".join(
2420         traceback.format_exception(excepttype, value, tracebackdata)
2421     )
2422
2423     # try to log it, if possible
2424     try:
2425         log(message, 9)
2426     except Exception as e:
2427         # try to write it to stderr, if possible
2428         sys.stderr.write(message + "\nException while logging...\n%s" % e)
2429
2430
2431 def sighook(what, where):
2432     """Handle external signals."""
2433
2434     # a generic message
2435     message = "Caught signal: "
2436
2437     # for a hangup signal
2438     if what == signal.SIGHUP:
2439         message += "hangup (reloading)"
2440         universe.reload_flag = True
2441
2442     # for a terminate signal
2443     elif what == signal.SIGTERM:
2444         message += "terminate (halting)"
2445         universe.terminate_flag = True
2446
2447     # catchall for unexpected signals
2448     else:
2449         message += str(what) + " (unhandled)"
2450
2451     # log what happened
2452     log(message, 8)
2453
2454
2455 def override_excepthook():
2456     """Redefine sys.excepthook with our own."""
2457     sys.excepthook = excepthook
2458
2459
2460 def assign_sighook():
2461     """Assign a customized handler for some signals."""
2462     signal.signal(signal.SIGHUP, sighook)
2463     signal.signal(signal.SIGTERM, sighook)
2464
2465
2466 def setup():
2467     """This contains functions to be performed when starting the engine."""
2468
2469     # see if a configuration file was specified
2470     if len(sys.argv) > 1:
2471         conffile = sys.argv[1]
2472     else:
2473         conffile = ""
2474
2475     # the big bang
2476     global universe
2477     universe = Universe(conffile, True)
2478
2479     # report any loglines which accumulated during setup
2480     for logline in universe.setup_loglines:
2481         log(*logline)
2482     universe.setup_loglines = []
2483
2484     # log an initial message
2485     log("Started mudpy with command line: " + " ".join(sys.argv))
2486
2487     # fork and disassociate
2488     daemonize(universe)
2489
2490     # override the default exception handler so we get logging first thing
2491     override_excepthook()
2492
2493     # set up custom signal handlers
2494     assign_sighook()
2495
2496     # make the pidfile
2497     create_pidfile(universe)
2498
2499     # pass the initialized universe back
2500     return universe
2501
2502
2503 def finish():
2504     """These are functions performed when shutting down the engine."""
2505
2506     # the loop has terminated, so save persistent data
2507     universe.save()
2508
2509     # log a final message
2510     log("Shutting down now.")
2511
2512     # get rid of the pidfile
2513     remove_pidfile(universe)